@onlineapps/conn-orch-api-mapper 1.0.34 → 2.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.
Files changed (2) hide show
  1. package/package.json +1 -2
  2. package/src/ApiMapper.js +16 -920
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-api-mapper",
3
- "version": "1.0.34",
3
+ "version": "2.0.0",
4
4
  "description": "API mapping connector for OA Drive - maps cookbook operations to HTTP endpoints",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -20,7 +20,6 @@
20
20
  "author": "OA Drive Team",
21
21
  "license": "MIT",
22
22
  "dependencies": {
23
- "axios": "^1.4.0",
24
23
  "@onlineapps/content-resolver": "1.1.16"
25
24
  },
26
25
  "devDependencies": {
package/src/ApiMapper.js CHANGED
@@ -1,931 +1,27 @@
1
1
  'use strict';
2
2
 
3
- const axios = require('axios');
4
- const ContentAccessor = require('@onlineapps/content-resolver');
5
-
6
3
  /**
7
- * @class ApiMapper
8
- * @description Maps cookbook operations to HTTP API endpoints using the
9
- * operations.json contract (see operations-registry-contract.md §3). A legacy
10
- * OpenAPI `paths` payload is still accepted as a fallback during migration.
4
+ * @onlineapps/conn-orch-api-mapper
11
5
  *
12
- * Handles request building, variable resolution, and response transformation.
6
+ * Retired on 2026-04 as part of the biz-service invocation model C-refactor.
7
+ * The HTTP-loopback dispatch (callOperation + _callViaHttp + _callDirectly +
8
+ * _buildRequest + _convertOperationsJsonInputToOpenApi) has been removed; biz
9
+ * services now invoke handlers directly via
10
+ * @onlineapps/service-wrapper >= 3.0.0 (HandlerRegistry).
13
11
  *
14
- * This is the core logic moved from service-wrapper's ApiCaller to make
15
- * service-wrapper a true thin orchestration layer.
12
+ * See: api/docs/architecture/biz-service-invocation-model.md
16
13
  *
17
- * @see api/docs/standards/operations-registry-contract.md §3 Operation schema
14
+ * This package stays on 1.x as a retirement marker; version bump to 2.0.0 and
15
+ * removal from consumer dependencies happen in P1.8 and P5.1 of
16
+ * .cursor/plans/biz_invocation_model_c_1a797518.plan.md.
18
17
  */
19
- class ApiMapper {
20
- /**
21
- * Create a new ApiMapper instance.
22
- *
23
- * @constructor
24
- * @param {Object} config - Configuration object
25
- * @param {Object} config.operations - Operations specification. Primary format:
26
- * `{ operations: { <operationId>: { method, endpoint, input, output, ... } } }`
27
- * (see operations-registry-contract.md §3). Legacy OpenAPI `{ paths }`
28
- * payloads are still accepted as a fallback.
29
- * @param {string} config.serviceUrl - Base URL of the service (required; no topology defaults)
30
- * @param {Object} [config.service] - Express app instance for direct calls
31
- * @param {boolean} [config.directCall=false] - Use direct Express calls instead of HTTP
32
- * @param {Object} config.logger - Logger instance (required; must expose `warn`)
33
- *
34
- * @example
35
- * const apiMapper = new ApiMapper({
36
- * operations: require('./config/service/operations.json'),
37
- * serviceUrl: 'http://hello-service:3000',
38
- * logger
39
- * });
40
- */
41
- constructor(config) {
42
- if (!config.operations) {
43
- throw new Error('[ApiMapper] operations specification is required (see operations-registry-contract.md §3)');
44
- }
45
-
46
- if (typeof config.serviceUrl !== 'string' || config.serviceUrl.trim().length === 0) {
47
- throw new Error('[ApiMapper] Missing configuration - serviceUrl is required (no defaults)');
48
- }
49
-
50
- /**
51
- * Raw operations specification as provided by the caller. For operations.json
52
- * payloads this is `{ operations: { ... } }`; for legacy OpenAPI it is
53
- * `{ paths: { ... } }`.
54
- */
55
- this.operationsSpec = config.operations;
56
- this.serviceUrl = config.serviceUrl;
57
- this.service = config.service;
58
- this.directCall = config.directCall === true;
59
- if (!config.logger || typeof config.logger.warn !== 'function') {
60
- throw new Error('[ApiMapper] Logger is required — Expected object with warn() method');
61
- }
62
- this.logger = config.logger;
63
-
64
- // Parsed operation map: <operationId> → endpoint/HTTP details used at runtime.
65
- this.operations = this._parseOperations(this.operationsSpec);
66
- }
67
-
68
- /**
69
- * Load (or reload) the operations specification.
70
- *
71
- * @method loadOperations
72
- * @param {Object} spec - Operations specification (see constructor)
73
- * @returns {Object} Parsed operation map
74
- */
75
- loadOperations(spec) {
76
- this.operationsSpec = spec;
77
- this.operations = this._parseOperations(spec);
78
- return this.operations;
79
- }
80
-
81
- /**
82
- * Map operation name to HTTP endpoint details
83
- * @method mapOperationToEndpoint
84
- * @param {string} operationName - Operation ID from cookbook
85
- * @returns {Object} Endpoint details {method, path, parameters}
86
- *
87
- * @example
88
- * const endpoint = apiMapper.mapOperationToEndpoint('getUser');
89
- * // Returns: {method: 'GET', path: '/users/{id}', parameters: [...]}
90
- */
91
- mapOperationToEndpoint(operationName) {
92
- const operation = this.operations[operationName];
93
- if (!operation) {
94
- throw new Error(`Operation not found: ${operationName}`);
95
- }
96
- return operation;
97
- }
98
-
99
- /**
100
- * Transform cookbook parameters to HTTP request
101
- * @method transformRequest
102
- * @param {Object} cookbookParams - Parameters from cookbook step
103
- * @param {Object} openApiParams - OpenAPI parameter definitions
104
- * @returns {Object} Transformed request {params, query, body, headers}
105
- *
106
- * @example
107
- * const request = apiMapper.transformRequest(
108
- * {userId: '123', name: 'John'},
109
- * operation.parameters
110
- * );
111
- */
112
- transformRequest(cookbookParams, openApiParams) {
113
- const result = {
114
- params: {},
115
- query: {},
116
- body: null,
117
- headers: {}
118
- };
119
-
120
- // Process each parameter according to OpenAPI spec
121
- openApiParams.forEach(param => {
122
- const value = cookbookParams[param.name];
123
- if (value !== undefined) {
124
- switch (param.in) {
125
- case 'path':
126
- result.params[param.name] = value;
127
- break;
128
- case 'query':
129
- result.query[param.name] = value;
130
- break;
131
- case 'header':
132
- result.headers[param.name] = value;
133
- break;
134
- }
135
- } else if (param.required) {
136
- throw new Error(`Required parameter missing: ${param.name}`);
137
- }
138
- });
139
-
140
- // Handle request body
141
- if (cookbookParams.body) {
142
- result.body = cookbookParams.body;
143
- }
144
-
145
- return result;
146
- }
147
-
148
- /**
149
- * Transform HTTP response to cookbook format
150
- *
151
- * CRITICAL RULES:
152
- * 1. Handler returns RAW data (temp_path, filename, content_type) - NEVER Descriptors!
153
- * 2. ApiMapper is the ONLY place that creates Descriptors
154
- * 3. FAIL-FAST on unexpected formats - no fallbacks!
155
- *
156
- * @method transformResponse
157
- * @param {Object} httpResponse - HTTP response from handler
158
- * @param {Object} [operation] - Operation schema from operations.json
159
- * @param {Object} [context] - Workflow context { workflow_id, step_id }
160
- * @returns {Promise<Object>} Transformed response (Descriptor for file types)
161
- */
162
- async transformResponse(httpResponse, operation = null, context = {}) {
163
- // Extract relevant data from HTTP response
164
- let output = httpResponse.data || httpResponse;
165
-
166
- const workflowId = context.workflow_id || 'standalone';
167
- const stepId = context.step_id || 'unknown';
168
- const logPrefix = `[${workflowId}:${stepId}:ApiMapper`;
169
-
170
- // === LOGGING: Input ===
171
- console.log(`${logPrefix}:PROCESS] ${JSON.stringify({
172
- action: 'transform_start',
173
- hasOperation: !!operation,
174
- outputType: typeof output,
175
- outputKeys: output && typeof output === 'object' ? Object.keys(output) : null
176
- })}`);
177
-
178
- // === FAIL-FAST: Handler should NEVER return Descriptors ===
179
- if (output && typeof output === 'object') {
180
- if (output._descriptor === true) {
181
- const errorMsg = 'Handler returned _descriptor:true - handlers must return RAW data, not Descriptors!';
182
- console.error(`${logPrefix}:FAIL] ${JSON.stringify({
183
- error: 'HANDLER_RETURNED_DESCRIPTOR',
184
- message: errorMsg,
185
- handler_output_keys: Object.keys(output),
186
- expected: 'object with temp_path/filename or raw data'
187
- })}`);
188
- throw new Error(`[ApiMapper] FAIL-FAST: ${errorMsg}`);
189
- }
190
-
191
- // Also fail if handler returned storage_ref directly (handler shouldn't know about MinIO)
192
- if (output.storage_ref && output.storage_ref.startsWith('minio://')) {
193
- const errorMsg = 'Handler returned storage_ref - handlers must not interact with MinIO directly!';
194
- console.error(`${logPrefix}:FAIL] ${JSON.stringify({
195
- error: 'HANDLER_RETURNED_STORAGE_REF',
196
- message: errorMsg,
197
- storage_ref: output.storage_ref,
198
- expected: 'object with temp_path/filename'
199
- })}`);
200
- throw new Error(`[ApiMapper] FAIL-FAST: ${errorMsg}`);
201
- }
202
- }
203
-
204
- // If no operation schema, return as-is (for non-file outputs)
205
- if (!operation || !operation.output) {
206
- console.log(`${logPrefix}:PROCESS] ${JSON.stringify({
207
- action: 'no_schema',
208
- result: 'returning_as_is'
209
- })}`);
210
- return output;
211
- }
212
-
213
- const outputSchema = operation.output;
214
-
215
- // Initialize ContentResolver (lazy)
216
- let contentResolver = null;
217
- const getContentResolver = () => {
218
- if (!contentResolver) {
219
- contentResolver = new ContentAccessor({
220
- context: { workflow_id: workflowId, step_id: stepId },
221
- logger: this.logger
222
- });
223
- }
224
- return contentResolver;
225
- };
226
-
227
- // === DETECT OUTPUT SCHEMA FORMAT ===
228
- // Format A: Direct type - { type: "file", fields: {...} }
229
- // Format B: Named fields - { "fieldName": { type: "string" }, ... }
230
- const isDirectType = outputSchema.type &&
231
- ['file', 'content', 'string', 'object', 'array'].includes(outputSchema.type);
232
-
233
- console.log(`${logPrefix}:PROCESS] ${JSON.stringify({
234
- action: 'schema_detected',
235
- format: isDirectType ? 'DIRECT_TYPE' : 'NAMED_FIELDS',
236
- schemaType: outputSchema.type
237
- })}`);
238
-
239
- // === FORMAT A: Direct type (entire output is one value) ===
240
- if (isDirectType) {
241
- const schemaType = outputSchema.type;
242
-
243
- // For file/content types, convert to Descriptor
244
- if (schemaType === 'file' || schemaType === 'content') {
245
- return await this._convertToDescriptor(output, outputSchema, getContentResolver, logPrefix, context);
246
- }
247
-
248
- // For other types, return as-is
249
- console.log(`${logPrefix}:PROCESS] ${JSON.stringify({
250
- action: 'pass_through',
251
- schemaType: schemaType
252
- })}`);
253
- return output;
254
- }
255
-
256
- // === FORMAT B: Named fields ===
257
- const normalizedOutput = { ...output };
258
-
259
- for (const [fieldName, fieldSchema] of Object.entries(outputSchema)) {
260
- if (!fieldSchema || typeof fieldSchema !== 'object') continue;
261
-
262
- const fieldType = fieldSchema.type;
263
- const fieldValue = output[fieldName];
264
-
265
- if (fieldValue === undefined || fieldValue === null) continue;
266
-
267
- // For file/content fields, convert to Descriptor
268
- if (fieldType === 'file' || fieldType === 'content') {
269
- normalizedOutput[fieldName] = await this._convertToDescriptor(
270
- fieldValue, fieldSchema, getContentResolver, logPrefix, context, fieldName
271
- );
272
- }
273
- }
274
-
275
- console.log(`${logPrefix}:PROCESS] ${JSON.stringify({
276
- action: 'transform_complete',
277
- outputKeys: Object.keys(normalizedOutput)
278
- })}`);
279
-
280
- return normalizedOutput;
281
- }
282
-
283
- /**
284
- * Convert handler output to Descriptor
285
- * @private
286
- *
287
- * Supported formats from handler/HTTP response:
288
- * 1. { data: base64_string, encoding: 'base64', filename, content_type } - HTTP transport format
289
- * 2. { temp_path, filename, content_type } - Local file (only for same-process calls)
290
- * 3. Buffer - Raw binary data
291
- * 4. string - Text content
292
- */
293
- async _convertToDescriptor(output, schema, getContentResolver, logPrefix, context, fieldName = null) {
294
- const fieldLabel = fieldName ? `field '${fieldName}'` : 'output';
295
- const forceFile = schema?.type === 'file';
296
-
297
- // Format 1: Base64-encoded data from HTTP response (primary format for distributed services)
298
- if (output && typeof output === 'object' && output.data && output.encoding === 'base64') {
299
- console.log(`${logPrefix}:PROCESS] ${JSON.stringify({
300
- action: 'convert_base64',
301
- field: fieldName,
302
- filename: output.filename,
303
- size: output.size
304
- })}`);
305
-
306
- // Decode base64 to Buffer
307
- const buffer = Buffer.from(output.data, 'base64');
308
-
309
- const resolver = getContentResolver();
310
- const descriptor = await resolver.createDescriptor(buffer, {
311
- filename: output.filename || schema.filename || 'output.bin',
312
- content_type: output.content_type || schema.content_type,
313
- context: {
314
- workflow_id: context.workflow_id || 'standalone',
315
- step_id: context.step_id || 'api-mapper'
316
- },
317
- forceFile: true // Binary data → always store as file
318
- });
319
-
320
- console.log(`${logPrefix}:PROCESS] ${JSON.stringify({
321
- action: 'descriptor_created',
322
- field: fieldName,
323
- storage_ref: descriptor.storage_ref,
324
- size: descriptor.size
325
- })}`);
326
-
327
- return descriptor;
328
- }
329
-
330
- // Format 2: Temp file path (for same-process calls, e.g. tests)
331
- if (output && typeof output === 'object' && output.temp_path) {
332
- console.log(`${logPrefix}:PROCESS] ${JSON.stringify({
333
- action: 'convert_temp_file',
334
- field: fieldName,
335
- temp_path: output.temp_path,
336
- filename: output.filename
337
- })}`);
338
-
339
- const resolver = getContentResolver();
340
- const descriptor = await resolver.createDescriptorFromFile(output.temp_path, {
341
- filename: output.filename,
342
- content_type: output.content_type,
343
- context: {
344
- workflow_id: context.workflow_id || 'standalone',
345
- step_id: context.step_id || 'api-mapper'
346
- },
347
- deleteAfterUpload: true
348
- });
349
-
350
- console.log(`${logPrefix}:PROCESS] ${JSON.stringify({
351
- action: 'descriptor_created',
352
- field: fieldName,
353
- storage_ref: descriptor.storage_ref,
354
- size: descriptor.size
355
- })}`);
356
-
357
- return descriptor;
358
- }
359
-
360
- // Format 3: Raw Buffer
361
- if (Buffer.isBuffer(output)) {
362
- console.log(`${logPrefix}:PROCESS] ${JSON.stringify({
363
- action: 'convert_buffer',
364
- field: fieldName,
365
- size: output.length
366
- })}`);
367
-
368
- const resolver = getContentResolver();
369
- return await resolver.createDescriptor(output, {
370
- filename: schema.filename || 'output.bin',
371
- content_type: schema.content_type,
372
- context: {
373
- workflow_id: context.workflow_id || 'standalone',
374
- step_id: context.step_id || 'api-mapper'
375
- },
376
- forceFile: true
377
- });
378
- }
379
-
380
- // Format 4: Plain string (text content)
381
- if (typeof output === 'string') {
382
- console.log(`${logPrefix}:PROCESS] ${JSON.stringify({
383
- action: 'convert_string',
384
- field: fieldName,
385
- length: output.length
386
- })}`);
387
-
388
- const resolver = getContentResolver();
389
- return await resolver.createDescriptor(output, {
390
- filename: schema.filename,
391
- content_type: schema.content_type,
392
- context: {
393
- workflow_id: context.workflow_id || 'standalone',
394
- step_id: context.step_id || 'api-mapper'
395
- },
396
- ...(forceFile ? { forceFile: true } : {})
397
- });
398
- }
399
-
400
- // FAIL-FAST: Unexpected format
401
- const errorMsg = `${fieldLabel} expects file/content but got unexpected format`;
402
- console.error(`${logPrefix}:FAIL] ${JSON.stringify({
403
- error: 'UNEXPECTED_OUTPUT_FORMAT',
404
- field: fieldName,
405
- expected: '{ data, encoding: "base64" } or { temp_path } or Buffer or string',
406
- received: typeof output,
407
- receivedKeys: output && typeof output === 'object' ? Object.keys(output) : null
408
- })}`);
409
- throw new Error(`[ApiMapper] FAIL-FAST: ${errorMsg}. Expected base64/temp_path/Buffer/string, got ${typeof output}`);
410
- }
411
-
412
- /**
413
- * Call operation with parameters
414
- * @async
415
- * @method callOperation
416
- * @param {string} operationId - Operation identifier
417
- * @param {Object} input - Input data for the operation
418
- * @param {Object} context - Workflow context
419
- * @returns {Promise<Object>} API response
420
- *
421
- * @example
422
- * const result = await apiMapper.callOperation('getUser', {id: '123'}, context);
423
- */
424
- async callOperation(operationId, input = {}, context = {}) {
425
- const operation = this.operations[operationId];
426
-
427
- if (!operation) {
428
- throw new Error(`Operation not found: ${operationId}`);
429
- }
430
-
431
- try {
432
- // Resolve variables in input using context
433
- const resolvedInput = this._resolveVariables(input, context);
434
-
435
- // CRITICAL: Resolve Content Descriptors to raw data BEFORE calling handler
436
- // Handler receives RAW DATA, not Descriptors! ApiMapper is the boundary.
437
- const handlerInput = await this._resolveContentDescriptors(resolvedInput, operation);
438
-
439
- // Build request
440
- const request = this._buildRequest(operation, handlerInput, context);
441
-
442
- // Make the call
443
- let response;
444
- if (this.service && this.directCall) {
445
- response = await this._callDirectly(request);
446
- } else {
447
- response = await this._callViaHttp(request);
448
- }
449
-
450
- // Transform and return response (with output normalization)
451
- return await this.transformResponse(response, operation, context);
452
-
453
- } catch (error) {
454
- this.logger.error(`API call failed for ${operationId}`, {
455
- error: error.message,
456
- input,
457
- operation
458
- });
459
- throw error;
460
- }
461
- }
462
-
463
- /**
464
- * Resolve Content Descriptors in input to raw data
465
- * CRITICAL: Handler receives RAW DATA, not Descriptors!
466
- * This is the boundary between orchestration (Descriptors) and business logic (raw data).
467
- *
468
- * @private
469
- * @param {Object} input - Input with potential Content Descriptors
470
- * @param {Object} operation - Operation definition (for type hints)
471
- * @returns {Promise<Object>} Input with Descriptors resolved to raw data
472
- */
473
- async _resolveContentDescriptors(input, operation) {
474
- if (!input || typeof input !== 'object') {
475
- return input;
476
- }
477
-
478
- // LAZY initialization - ContentResolver only when actually needed
479
- let resolver = null;
480
- const getResolver = () => {
481
- if (!resolver) {
482
- resolver = new ContentAccessor({ logger: this.logger });
483
- }
484
- return resolver;
485
- };
486
-
487
- // Get input schema from operation (if available)
488
- const inputSchema = operation?.input || {};
489
-
490
- const resolveValue = async (value, schema, keyPath = '') => {
491
- // If descriptor, resolve based on type
492
- const isDescriptor = value && typeof value === 'object' &&
493
- (value._descriptor === true || value.ref || value.storage_ref);
494
-
495
- // Some upstream steps may serialize arrays into array-like objects: { "0": ..., "1": ... }.
496
- // If schema expects an array, normalize such objects back into arrays so item schemas are applied correctly.
497
- // Without this, nested descriptors (e.g. attachments[].value) may lose their schema.type='file' and get resolved
498
- // into strings, dropping filename/content_type metadata.
499
- if (!Array.isArray(value) && value && typeof value === 'object' && schema?.type === 'array') {
500
- const keys = Object.keys(value);
501
- const isNumericKeyed =
502
- keys.length > 0 &&
503
- keys.every((k) => /^[0-9]+$/.test(k));
504
- if (isNumericKeyed) {
505
- const asArray = keys
506
- .sort((a, b) => Number(a) - Number(b))
507
- .map((k) => value[k]);
508
- return resolveValue(asArray, schema, keyPath);
509
- }
510
- }
511
-
512
- if (isDescriptor) {
513
- const fieldType = schema?.type;
514
- const storageRef = value.ref || value.storage_ref || value;
515
-
516
- if (fieldType === 'file') {
517
- // Pass-through for file type
518
- return value;
519
- }
520
-
521
- try {
522
- const str = await getResolver().getAsString(storageRef);
523
- return str;
524
- } catch (err) {
525
- const label = keyPath || '<root>';
526
- throw new Error(`Failed to resolve Content Descriptor for '${label}': ${err.message}`);
527
- }
528
- }
529
-
530
- // Array: resolve items with item schema if available
531
- if (Array.isArray(value)) {
532
- const itemSchema = schema?.items || {};
533
- const resolvedItems = [];
534
- for (let i = 0; i < value.length; i++) {
535
- resolvedItems.push(await resolveValue(value[i], itemSchema, `${keyPath}[${i}]`));
536
- }
537
- return resolvedItems;
538
- }
539
-
540
- // Object: recurse into properties (schema.properties if provided)
541
- if (value && typeof value === 'object') {
542
- const resultObj = {};
543
- for (const [k, v] of Object.entries(value)) {
544
- const childSchema = schema?.properties?.[k] || {};
545
- resultObj[k] = await resolveValue(v, childSchema, keyPath ? `${keyPath}.${k}` : k);
546
- }
547
- return resultObj;
548
- }
549
-
550
- // Primitive or null
551
- return value;
552
- };
553
-
554
- const resolved = {};
555
- for (const [key, value] of Object.entries(input)) {
556
- const fieldSchema = inputSchema[key] || {};
557
- resolved[key] = await resolveValue(value, fieldSchema, key);
558
- }
559
-
560
- return resolved;
561
- }
562
-
563
- /**
564
- * Parse operations specification (operations.json primary, OpenAPI fallback)
565
- * into a runtime operation map keyed by operationId.
566
- *
567
- * @private
568
- * @param {Object} spec - operations.json payload or legacy OpenAPI spec
569
- * @returns {Object} Operations map
570
- */
571
- _parseOperations(spec) {
572
- const operations = {};
573
-
574
- if (!spec || typeof spec !== 'object') {
575
- return operations;
576
- }
577
-
578
- // Check if this is operations.json format (has "operations" key at root)
579
- if (spec.operations && typeof spec.operations === 'object') {
580
- // Parse operations.json format
581
- Object.entries(spec.operations).forEach(([operationId, operation]) => {
582
- operations[operationId] = {
583
- method: (operation.method || 'POST').toUpperCase(),
584
- path: operation.endpoint || `/${operationId}`,
585
- // Static headers from operations.json (e.g. x-validation-request).
586
- headers: operation.headers || null,
587
- // Store original operations.json input schema for type-driven descriptor handling
588
- input: operation.input || null,
589
- parameters: operation.input ? this._convertOperationsJsonInputToOpenApi(operation.input) : [],
590
- requestBody: operation.input ? { content: { 'application/json': { schema: { type: 'object', properties: operation.input } } } } : undefined,
591
- responses: operation.output ? { '200': { description: 'Success', content: { 'application/json': { schema: { type: 'object', properties: operation.output } } } } } : { '200': { description: 'Success' } },
592
- summary: operation.description,
593
- description: operation.description,
594
- // Store original operations.json output schema for normalization
595
- output: operation.output || null
596
- };
597
- });
598
- this.logger?.info(`Parsed ${Object.keys(operations).length} operations from operations.json format`);
599
- return operations;
600
- }
601
18
 
602
- // Extract all operations from OpenAPI paths (original OpenAPI format)
603
- if (spec.paths) {
604
- Object.entries(spec.paths).forEach(([path, pathItem]) => {
605
- Object.entries(pathItem).forEach(([method, operation]) => {
606
- if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
607
- const operationId = operation.operationId ||
608
- `${method}_${path.replace(/[^a-zA-Z0-9]/g, '_')}`;
609
-
610
- operations[operationId] = {
611
- method: method.toUpperCase(),
612
- path,
613
- parameters: operation.parameters || [],
614
- requestBody: operation.requestBody,
615
- responses: operation.responses,
616
- summary: operation.summary,
617
- description: operation.description
618
- };
619
- }
620
- });
621
- });
622
- }
623
-
624
- this.logger?.info(`Parsed ${Object.keys(operations).length} operations from OpenAPI spec`);
625
- return operations;
626
- }
627
-
628
- /**
629
- * Convert operations.json input format to OpenAPI parameters
630
- * @private
631
- * @param {Object} input - operations.json input schema
632
- * @returns {Array} OpenAPI parameters array
633
- */
634
- _convertOperationsJsonInputToOpenApi(input) {
635
- const parameters = [];
636
- Object.entries(input).forEach(([name, schema]) => {
637
- parameters.push({
638
- name,
639
- in: 'body', // operations.json uses body parameters
640
- required: schema.required !== false,
641
- schema: {
642
- type: schema.type || 'string',
643
- description: schema.description,
644
- minLength: schema.minLength,
645
- maxLength: schema.maxLength,
646
- enum: schema.enum,
647
- default: schema.default
648
- }
649
- });
650
- });
651
- return parameters;
652
- }
653
-
654
- /**
655
- * Resolve variables in input using context
656
- * @private
657
- * @param {Object} input - Input with potential variable references
658
- * @param {Object} context - Context containing variable values
659
- * @returns {Object} Resolved input
660
- */
661
- _resolveVariables(input, context) {
662
- if (!input || typeof input !== 'object') {
663
- return input;
664
- }
665
-
666
- const resolved = {};
667
-
668
- Object.entries(input).forEach(([key, value]) => {
669
- if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
670
- // Variable reference: ${context.path.to.value}
671
- const path = value.slice(2, -1);
672
- resolved[key] = this._getValueFromPath(context, path);
673
- } else if (typeof value === 'object' && value !== null) {
674
- // Recursive resolution
675
- resolved[key] = this._resolveVariables(value, context);
676
- } else {
677
- resolved[key] = value;
678
- }
679
- });
680
-
681
- return resolved;
682
- }
683
-
684
- /**
685
- * Get value from object using dot notation path
686
- * @private
687
- * @param {Object} obj - Object to search
688
- * @param {string} path - Dot notation path
689
- * @returns {*} Value at path
690
- */
691
- _getValueFromPath(obj, path) {
692
- const parts = path.split('.');
693
- let current = obj;
694
-
695
- for (const part of parts) {
696
- if (current && typeof current === 'object' && part in current) {
697
- current = current[part];
698
- } else {
699
- return undefined;
700
- }
701
- }
702
-
703
- return current;
704
- }
705
-
706
- /**
707
- * Build HTTP request from operation and input
708
- * @private
709
- * @param {Object} operation - Operation definition
710
- * @param {Object} input - Resolved input
711
- * @param {Object} context - Workflow context (must include _system.tenant_id + _system.default_workspace_id)
712
- * @returns {Object} Request object
713
- */
714
- _buildRequest(operation, input, context = {}) {
715
- const request = {
716
- method: operation.method,
717
- url: this.serviceUrl + operation.path,
718
- params: {},
719
- headers: {},
720
- data: null
721
- };
722
-
723
- // operations.json static headers (if present)
724
- if (operation && operation.headers && typeof operation.headers === 'object') {
725
- Object.entries(operation.headers).forEach(([k, v]) => {
726
- if (!k) return;
727
- // Optional: allow ${context.path} variable resolution (no env side-effects here).
728
- if (typeof v === 'string' && v.startsWith('${') && v.endsWith('}')) {
729
- const path = v.slice(2, -1);
730
- const resolved = this._getValueFromPath(context, path);
731
- if (resolved !== undefined) {
732
- request.headers[k] = resolved;
733
- }
734
- return;
735
- }
736
-
737
- request.headers[k] = v;
738
- });
739
- }
740
-
741
- // Process path parameters
742
- operation.parameters.forEach(param => {
743
- const value = input[param.name];
744
-
745
- if (param.in === 'path') {
746
- // Replace path parameter in URL
747
- request.url = request.url.replace(`{${param.name}}`, value);
748
- } else if (param.in === 'query') {
749
- request.params[param.name] = value;
750
- } else if (param.in === 'header') {
751
- request.headers[param.name] = value;
752
- }
753
- });
754
-
755
- // For operations.json format: input is sent as request body for POST/PUT/PATCH
756
- // For OpenAPI format: use requestBody if present, otherwise send input as body
757
- if (['POST', 'PUT', 'PATCH'].includes(operation.method)) {
758
- if (operation.requestBody) {
759
- // OpenAPI format: use requestBody structure
760
- request.data = input.body || input;
761
- } else {
762
- // operations.json format or no requestBody: send input directly as body
763
- request.data = input;
764
- }
765
- request.headers['Content-Type'] = 'application/json';
766
- } else if (operation.method === 'GET' || operation.method === 'DELETE') {
767
- // For GET/DELETE:
768
- // - OpenAPI: rely on explicit operation.parameters (path/query/header), do NOT blindly copy all input to query
769
- // - operations.json: no explicit query params → copy input as query
770
- const hasExplicitNonBodyParams = Array.isArray(operation.parameters) &&
771
- operation.parameters.some(p => p && p.in && p.in !== 'body');
772
-
773
- if (!hasExplicitNonBodyParams) {
774
- Object.entries(input).forEach(([key, value]) => {
775
- if (value !== undefined && value !== null) {
776
- request.params[key] = value;
777
- }
778
- });
779
- }
780
- }
781
-
782
- const sys = context && typeof context === 'object' ? context._system : null;
783
- if (!sys) {
784
- throw new Error(
785
- '[ApiMapper][TenantContext] Missing _system context - Expected context._system with tenant_id. ' +
786
- 'Fix: Gateway must set _system.tenant_id.'
787
- );
788
- }
789
-
790
- if (sys.tenant_id === undefined) {
791
- throw new Error(
792
- '[ApiMapper][TenantContext] Missing tenant context - Expected _system.tenant_id'
793
- );
794
- }
795
-
796
- const tenantId = Number.parseInt(String(sys.tenant_id), 10);
797
- if (!Number.isInteger(tenantId) || tenantId <= 0) {
798
- throw new Error(`[ApiMapper][TenantContext] Invalid tenant_id - Expected positive integer, got: ${sys.tenant_id}`);
799
- }
800
-
801
- // workspace_id: per-step override → cookbook default → error
802
- // See: .cursor/rules/workspace-architecture.mdc
803
- const stepDef = context._pointer?.currentStep || operation;
804
- const rawStepWs = stepDef?.workspace_id;
805
- const rawDefaultWs = sys.default_workspace_id;
806
- const rawWorkspaceId = rawStepWs !== undefined && rawStepWs !== null ? rawStepWs : rawDefaultWs;
807
-
808
- if (rawWorkspaceId === undefined || rawWorkspaceId === null) {
809
- throw new Error(
810
- '[ApiMapper][TenantContext] Missing workspace_id - set per-step workspace_id or cookbook defaults.workspace_id'
811
- );
812
- }
813
-
814
- const workspaceId = Number.parseInt(String(rawWorkspaceId), 10);
815
- if (!Number.isInteger(workspaceId) || workspaceId <= 0) {
816
- throw new Error(`[ApiMapper][TenantContext] Invalid workspace_id - Expected positive integer, got: ${rawWorkspaceId}`);
817
- }
818
-
819
- const personId = sys.person_id !== undefined && sys.person_id !== null
820
- ? Number.parseInt(String(sys.person_id), 10)
821
- : undefined;
822
- if (personId === undefined || !Number.isInteger(personId) || personId <= 0) {
823
- throw new Error(`[ApiMapper][TenantContext] Invalid person_id - Expected positive integer, got: ${sys.person_id}`);
824
- }
825
-
826
- // See: docs/standards/tenant-context-contract.md (§3 Data Flow)
827
- request.headers['x-tenant-id'] = String(tenantId);
828
- request.headers['x-workspace-id'] = String(workspaceId);
829
- request.headers['x-person-id'] = String(personId);
830
-
831
- return request;
832
- }
833
-
834
- /**
835
- * Call service directly via Express app
836
- * @private
837
- * @async
838
- * @param {Object} request - Request object
839
- * @returns {Promise<Object>} Response
840
- */
841
- async _callDirectly(request) {
842
- // Create mock req/res objects for Express
843
- const mockReq = {
844
- method: request.method,
845
- url: request.url.replace(this.serviceUrl, ''),
846
- params: {},
847
- query: request.params,
848
- body: request.data,
849
- headers: request.headers
850
- };
851
-
852
- // Extract path params from URL
853
- const pathMatch = mockReq.url.match(/\/[^?]*/);
854
- if (pathMatch) {
855
- mockReq.path = pathMatch[0];
856
- }
857
-
858
- return new Promise((resolve, reject) => {
859
- const mockRes = {
860
- status: function(code) {
861
- this.statusCode = code;
862
- return this;
863
- },
864
- json: function(data) {
865
- resolve({ data, status: this.statusCode || 200 });
866
- },
867
- send: function(data) {
868
- resolve({ data, status: this.statusCode || 200 });
869
- }
870
- };
871
-
872
- // Call Express app directly
873
- this.service(mockReq, mockRes, (err) => {
874
- if (err) reject(err);
875
- else reject(new Error('Route not found'));
876
- });
877
- });
878
- }
879
-
880
- /**
881
- * Call service via HTTP
882
- * @private
883
- * @async
884
- * @param {Object} request - Request object
885
- * @returns {Promise<Object>} Response
886
- */
887
- async _callViaHttp(request) {
888
- try {
889
- const response = await axios(request);
890
- return response;
891
- } catch (error) {
892
- if (error.response) {
893
- const status = error.response.status;
894
- const data = error.response.data;
895
-
896
- const businessBody = data?.error || data;
897
- const remoteCode = businessBody?.code || businessBody?.errorCode || null;
898
- const remoteMessage = businessBody?.message || error.response.statusText;
899
- const remoteDetails = businessBody?.details || [];
900
- const remoteService = businessBody?.service || null;
901
- const remoteOperation = businessBody?.operation || null;
902
-
903
- const richMessage = remoteCode
904
- ? `[${remoteService || 'service'}] ${remoteCode}: ${remoteMessage}`
905
- : `API returned ${status}: ${remoteMessage}`;
906
-
907
- const richError = new Error(richMessage);
908
- richError.statusCode = status;
909
- richError.errorCode = remoteCode;
910
- richError.type = businessBody?.type || null;
911
- richError.details = remoteDetails;
912
- richError.service = remoteService;
913
- richError.operation = remoteOperation;
914
- richError.responseBody = data;
915
- richError.requestUrl = request.url;
916
-
917
- throw richError;
918
- } else if (error.request) {
919
- const noResponseError = new Error(`No response from service: ${request.url}`);
920
- noResponseError.statusCode = 503;
921
- noResponseError.type = 'TRANSIENT';
922
- noResponseError.requestUrl = request.url;
923
- throw noResponseError;
924
- } else {
925
- throw error;
926
- }
927
- }
19
+ class ApiMapper {
20
+ constructor() {
21
+ throw new Error(
22
+ '[ApiMapper] Package retired - Use @onlineapps/service-wrapper@^3.0.0 handler-registry dispatch instead. See api/docs/architecture/biz-service-invocation-model.md'
23
+ );
928
24
  }
929
25
  }
930
26
 
931
- module.exports = ApiMapper;
27
+ module.exports = ApiMapper;