@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
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
|
-
|
|
468
|
-
//
|
|
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
|
|
474
|
-
const
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
|
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
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
});
|