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