@onlineapps/conn-orch-api-mapper 1.0.13 → 1.0.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/conn-orch-api-mapper",
3
- "version": "1.0.13",
3
+ "version": "1.0.15",
4
4
  "description": "API mapping connector for OA Drive - maps cookbook operations to HTTP endpoints",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/ApiMapper.js CHANGED
@@ -269,6 +269,7 @@ class ApiMapper {
269
269
  */
270
270
  async _convertToDescriptor(output, schema, getContentResolver, logPrefix, context, fieldName = null) {
271
271
  const fieldLabel = fieldName ? `field '${fieldName}'` : 'output';
272
+ const forceFile = schema?.type === 'file';
272
273
 
273
274
  // Format 1: Base64-encoded data from HTTP response (primary format for distributed services)
274
275
  if (output && typeof output === 'object' && output.data && output.encoding === 'base64') {
@@ -368,7 +369,8 @@ class ApiMapper {
368
369
  context: {
369
370
  workflow_id: context.workflow_id || 'standalone',
370
371
  step_id: context.step_id || 'api-mapper'
371
- }
372
+ },
373
+ ...(forceFile ? { forceFile: true } : {})
372
374
  });
373
375
  }
374
376
 
@@ -459,46 +461,63 @@ class ApiMapper {
459
461
  return resolver;
460
462
  };
461
463
 
462
- const result = { ...input };
463
-
464
464
  // Get input schema from operation (if available)
465
465
  const inputSchema = operation?.input || {};
466
466
 
467
- for (const [key, value] of Object.entries(result)) {
468
- // Check if this is a Content Descriptor
469
- const isDescriptor = value && typeof value === 'object' &&
467
+ const resolveValue = async (value, schema, keyPath = '') => {
468
+ // If descriptor, resolve based on type
469
+ const isDescriptor = value && typeof value === 'object' &&
470
470
  (value._descriptor === true || value.ref || value.storage_ref);
471
-
471
+
472
472
  if (isDescriptor) {
473
- const fieldSchema = inputSchema[key] || {};
474
- const fieldType = fieldSchema.type;
475
-
476
- // Get storage reference
477
- const storageRef = value.ref || value.storage_ref;
478
-
479
- if (storageRef) {
480
- // Resolve based on field type from operations.json
481
- if (fieldType === 'file') {
482
- // For file type, handler expects the Descriptor (pass through)
483
- // Handler will use ContentResolver itself
484
- console.log(`[ApiMapper:INPUT_RESOLVE] Field '${key}' type=file, passing Descriptor through`);
485
- // Keep as-is
486
- } else {
487
- // For content/string types, resolve to raw string
488
- console.log(`[ApiMapper:INPUT_RESOLVE] Field '${key}' type=${fieldType || 'content'}, resolving Descriptor to string`);
489
- try {
490
- result[key] = await getResolver().getAsString(storageRef);
491
- console.log(`[ApiMapper:INPUT_RESOLVE] ✓ Resolved '${key}' from ${storageRef} (${result[key].length} chars)`);
492
- } catch (err) {
493
- console.error(`[ApiMapper:INPUT_RESOLVE] Failed to resolve '${key}': ${err.message}`);
494
- throw new Error(`Failed to resolve Content Descriptor for '${key}': ${err.message}`);
495
- }
496
- }
473
+ const fieldType = schema?.type;
474
+ const storageRef = value.ref || value.storage_ref || value;
475
+
476
+ if (fieldType === 'file') {
477
+ // Pass-through for file type
478
+ return value;
479
+ }
480
+
481
+ try {
482
+ const str = await getResolver().getAsString(storageRef);
483
+ return str;
484
+ } catch (err) {
485
+ const label = keyPath || '<root>';
486
+ throw new Error(`Failed to resolve Content Descriptor for '${label}': ${err.message}`);
487
+ }
488
+ }
489
+
490
+ // Array: resolve items with item schema if available
491
+ if (Array.isArray(value)) {
492
+ const itemSchema = schema?.items || {};
493
+ const resolvedItems = [];
494
+ for (let i = 0; i < value.length; i++) {
495
+ resolvedItems.push(await resolveValue(value[i], itemSchema, `${keyPath}[${i}]`));
496
+ }
497
+ return resolvedItems;
498
+ }
499
+
500
+ // Object: recurse into properties (schema.properties if provided)
501
+ if (value && typeof value === 'object') {
502
+ const resultObj = {};
503
+ for (const [k, v] of Object.entries(value)) {
504
+ const childSchema = schema?.properties?.[k] || {};
505
+ resultObj[k] = await resolveValue(v, childSchema, keyPath ? `${keyPath}.${k}` : k);
497
506
  }
507
+ return resultObj;
498
508
  }
509
+
510
+ // Primitive or null
511
+ return value;
512
+ };
513
+
514
+ const resolved = {};
515
+ for (const [key, value] of Object.entries(input)) {
516
+ const fieldSchema = inputSchema[key] || {};
517
+ resolved[key] = await resolveValue(value, fieldSchema, key);
499
518
  }
500
519
 
501
- return result;
520
+ return resolved;
502
521
  }
503
522
 
504
523
  /**
@@ -521,6 +540,8 @@ class ApiMapper {
521
540
  operations[operationId] = {
522
541
  method: (operation.method || 'POST').toUpperCase(),
523
542
  path: operation.endpoint || `/${operationId}`,
543
+ // Store original operations.json input schema for type-driven descriptor handling
544
+ input: operation.input || null,
524
545
  parameters: operation.input ? this._convertOperationsJsonInputToOpenApi(operation.input) : [],
525
546
  requestBody: operation.input ? { content: { 'application/json': { schema: { type: 'object', properties: operation.input } } } } : undefined,
526
547
  responses: operation.output ? { '200': { description: 'Success', content: { 'application/json': { schema: { type: 'object', properties: operation.output } } } } } : { '200': { description: 'Success' } },
@@ -680,12 +701,19 @@ class ApiMapper {
680
701
  }
681
702
  request.headers['Content-Type'] = 'application/json';
682
703
  } else if (operation.method === 'GET' || operation.method === 'DELETE') {
683
- // For GET/DELETE, add input to query params
684
- Object.entries(input).forEach(([key, value]) => {
685
- if (value !== undefined && value !== null) {
686
- request.params[key] = value;
687
- }
688
- });
704
+ // For GET/DELETE:
705
+ // - OpenAPI: rely on explicit operation.parameters (path/query/header), do NOT blindly copy all input to query
706
+ // - operations.json: no explicit query params copy input as query
707
+ const hasExplicitNonBodyParams = Array.isArray(operation.parameters) &&
708
+ operation.parameters.some(p => p && p.in && p.in !== 'body');
709
+
710
+ if (!hasExplicitNonBodyParams) {
711
+ Object.entries(input).forEach(([key, value]) => {
712
+ if (value !== undefined && value !== null) {
713
+ request.params[key] = value;
714
+ }
715
+ });
716
+ }
689
717
  }
690
718
 
691
719
  return request;
@@ -511,34 +511,34 @@ describe('API Mapper Component Tests @component', () => {
511
511
  });
512
512
 
513
513
  describe('Response Transformation', () => {
514
- it('should extract data from successful responses', () => {
514
+ it('should extract data from successful responses', async () => {
515
515
  const response = {
516
516
  data: { id: '1', name: 'John' },
517
517
  status: 200,
518
518
  headers: { 'content-type': 'application/json' }
519
519
  };
520
520
 
521
- const transformed = apiMapper.transformResponse(response);
521
+ const transformed = await apiMapper.transformResponse(response);
522
522
  expect(transformed).toEqual({ id: '1', name: 'John' });
523
523
  });
524
524
 
525
- it('should handle empty responses', () => {
525
+ it('should handle empty responses', async () => {
526
526
  const response = {
527
527
  data: null,
528
528
  status: 204
529
529
  };
530
530
 
531
- const transformed = apiMapper.transformResponse(response);
531
+ const transformed = await apiMapper.transformResponse(response);
532
532
  expect(transformed).toEqual(response);
533
533
  });
534
534
 
535
- it('should preserve error responses', () => {
535
+ it('should preserve error responses', async () => {
536
536
  const response = {
537
537
  data: { error: 'Bad request', details: 'Invalid email' },
538
538
  status: 400
539
539
  };
540
540
 
541
- const transformed = apiMapper.transformResponse(response);
541
+ const transformed = await apiMapper.transformResponse(response);
542
542
  expect(transformed).toEqual({ error: 'Bad request', details: 'Invalid email' });
543
543
  });
544
544
  });
@@ -4,6 +4,15 @@ const ApiMapper = require('../../src/ApiMapper');
4
4
  const axios = require('axios');
5
5
 
6
6
  jest.mock('axios');
7
+ jest.mock('@onlineapps/content-resolver', () => {
8
+ return jest.fn().mockImplementation(() => ({
9
+ getAsString: jest.fn(async (ref) => {
10
+ if (typeof ref === 'string') return `resolved:${ref}`;
11
+ if (ref && ref.content) return ref.content;
12
+ return 'resolved-descriptor';
13
+ })
14
+ }));
15
+ });
7
16
 
8
17
  describe('ApiMapper Unit Tests @unit', () => {
9
18
  let apiMapper;
@@ -150,6 +159,75 @@ describe('ApiMapper Unit Tests @unit', () => {
150
159
  });
151
160
  });
152
161
 
162
+ describe('operations.json format (type-driven descriptor handling)', () => {
163
+ it('should store original input schema on parsed operation', () => {
164
+ const operationsJson = {
165
+ operations: {
166
+ 'test-file-info': {
167
+ description: 'Test',
168
+ endpoint: '/api/test-file-info',
169
+ method: 'POST',
170
+ input: {
171
+ file: { type: 'file', required: true }
172
+ },
173
+ output: {
174
+ ok: { type: 'boolean' }
175
+ }
176
+ }
177
+ }
178
+ };
179
+
180
+ const mapper = new ApiMapper({
181
+ openApiSpec: operationsJson,
182
+ serviceUrl: 'http://test-service:3000',
183
+ logger: mockLogger
184
+ });
185
+
186
+ expect(mapper.operations['test-file-info']).toBeDefined();
187
+ expect(mapper.operations['test-file-info'].input).toEqual({
188
+ file: { type: 'file', required: true }
189
+ });
190
+ });
191
+
192
+ it('should pass through descriptors for input fields typed as file', async () => {
193
+ const operationsJson = {
194
+ operations: {
195
+ 'test-file-info': {
196
+ description: 'Test',
197
+ endpoint: '/api/test-file-info',
198
+ method: 'POST',
199
+ input: {
200
+ file: { type: 'file', required: true }
201
+ },
202
+ output: {
203
+ ok: { type: 'boolean' }
204
+ }
205
+ }
206
+ }
207
+ };
208
+
209
+ apiMapper = new ApiMapper({
210
+ openApiSpec: operationsJson,
211
+ serviceUrl: 'http://test-service:3000',
212
+ logger: mockLogger
213
+ });
214
+
215
+ // Ensure we do NOT hit axios
216
+ axios.mockImplementation(async () => ({ status: 200, data: { ok: true } }));
217
+
218
+ const descriptor = { _descriptor: true, type: 'file', storage_ref: 'minio://bucket/path' };
219
+ await apiMapper.callOperation('test-file-info', { file: descriptor }, {});
220
+
221
+ expect(axios).toHaveBeenCalledWith(
222
+ expect.objectContaining({
223
+ method: 'POST',
224
+ url: 'http://test-service:3000/api/test-file-info',
225
+ data: { file: descriptor }
226
+ })
227
+ );
228
+ });
229
+ });
230
+
153
231
  describe('mapOperationToEndpoint', () => {
154
232
  it('should return operation details for valid operation', () => {
155
233
  const endpoint = apiMapper.mapOperationToEndpoint('getUser');
@@ -233,30 +311,30 @@ describe('ApiMapper Unit Tests @unit', () => {
233
311
  });
234
312
 
235
313
  describe('transformResponse', () => {
236
- it('should extract data from response', () => {
314
+ it('should extract data from response', async () => {
237
315
  const response = {
238
316
  data: { id: '1', name: 'John' },
239
317
  status: 200,
240
318
  headers: {}
241
319
  };
242
320
 
243
- const result = apiMapper.transformResponse(response);
321
+ const result = await apiMapper.transformResponse(response);
244
322
  expect(result).toEqual({ id: '1', name: 'John' });
245
323
  });
246
324
 
247
- it('should handle null response', () => {
325
+ it('should handle null response', async () => {
248
326
  const response = {
249
327
  data: null,
250
328
  status: 204
251
329
  };
252
330
 
253
- const result = apiMapper.transformResponse(response);
331
+ const result = await apiMapper.transformResponse(response);
254
332
  expect(result).toEqual(response);
255
333
  });
256
334
 
257
- it('should pass through response without data property', () => {
335
+ it('should pass through response without data property', async () => {
258
336
  const response = { status: 200 };
259
- const result = apiMapper.transformResponse(response);
337
+ const result = await apiMapper.transformResponse(response);
260
338
  expect(result).toEqual(response);
261
339
  });
262
340
  });
@@ -387,13 +465,13 @@ describe('ApiMapper Unit Tests @unit', () => {
387
465
 
388
466
  const result = await apiMapper.callOperation('getUser', { userId: '1' });
389
467
 
390
- expect(axios).toHaveBeenCalledWith({
391
- method: 'GET',
392
- url: 'http://test-service:3000/users/1',
393
- params: {},
394
- headers: {},
395
- data: null
396
- });
468
+ expect(axios).toHaveBeenCalledWith(
469
+ expect.objectContaining({
470
+ method: 'GET',
471
+ url: 'http://test-service:3000/users/1',
472
+ params: {}
473
+ })
474
+ );
397
475
 
398
476
  expect(result).toEqual({ id: '1', name: 'John' });
399
477
  });
@@ -458,4 +536,64 @@ describe('ApiMapper Unit Tests @unit', () => {
458
536
  expect(result).toEqual({ id: '1' });
459
537
  });
460
538
  });
539
+
540
+ describe('_resolveContentDescriptors', () => {
541
+ it('should return input if not object', async () => {
542
+ const result = await apiMapper._resolveContentDescriptors(null);
543
+ expect(result).toBeNull();
544
+ });
545
+
546
+ it('should resolve nested descriptors based on schema types', async () => {
547
+ const input = {
548
+ doc: {
549
+ _descriptor: true,
550
+ type: 'file',
551
+ storage_ref: 'minio://workflow/file1'
552
+ },
553
+ meta: {
554
+ title: {
555
+ _descriptor: true,
556
+ type: 'inline',
557
+ content: 'Hello'
558
+ },
559
+ items: [
560
+ {
561
+ note: {
562
+ _descriptor: true,
563
+ type: 'inline',
564
+ content: 'Note 1'
565
+ }
566
+ }
567
+ ]
568
+ }
569
+ };
570
+
571
+ const operation = {
572
+ input: {
573
+ doc: { type: 'file' },
574
+ meta: {
575
+ type: 'object',
576
+ properties: {
577
+ title: { type: 'string' },
578
+ items: {
579
+ type: 'array',
580
+ items: {
581
+ type: 'object',
582
+ properties: {
583
+ note: { type: 'string' }
584
+ }
585
+ }
586
+ }
587
+ }
588
+ }
589
+ }
590
+ };
591
+
592
+ const result = await apiMapper._resolveContentDescriptors(input, operation);
593
+
594
+ expect(result.doc).toEqual(input.doc); // file passthrough
595
+ expect(result.meta.title).toBe('Hello');
596
+ expect(result.meta.items[0].note).toBe('Note 1');
597
+ });
598
+ });
461
599
  });