@onlineapps/conn-orch-api-mapper 1.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 +233 -0
- package/coverage/clover.xml +125 -0
- package/coverage/coverage-final.json +3 -0
- package/coverage/lcov-report/ApiMapper.js.html +1237 -0
- package/coverage/lcov-report/base.css +224 -0
- package/coverage/lcov-report/block-navigation.js +87 -0
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/index.html +131 -0
- package/coverage/lcov-report/index.js.html +205 -0
- package/coverage/lcov-report/prettify.css +1 -0
- package/coverage/lcov-report/prettify.js +2 -0
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +210 -0
- package/coverage/lcov.info +256 -0
- package/package.json +38 -0
- package/src/ApiMapper.js +385 -0
- package/src/index.js +41 -0
- package/test/component/api-mapping-flow.test.js +624 -0
- package/test/unit/ApiMapper.unit.test.js +461 -0
package/src/ApiMapper.js
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const axios = require('axios');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @class ApiMapper
|
|
7
|
+
* @description Maps cookbook operations to HTTP API endpoints using OpenAPI specification.
|
|
8
|
+
* Handles request building, variable resolution, and response transformation.
|
|
9
|
+
*
|
|
10
|
+
* This is the core logic moved from service-wrapper's ApiCaller to make
|
|
11
|
+
* service-wrapper a true thin orchestration layer.
|
|
12
|
+
*/
|
|
13
|
+
class ApiMapper {
|
|
14
|
+
/**
|
|
15
|
+
* Create a new ApiMapper instance
|
|
16
|
+
* @constructor
|
|
17
|
+
* @param {Object} config - Configuration object
|
|
18
|
+
* @param {Object|string} config.openApiSpec - OpenAPI specification object or path
|
|
19
|
+
* @param {string} [config.serviceUrl='http://localhost:3000'] - Base URL of the service
|
|
20
|
+
* @param {Object} [config.service] - Express app instance for direct calls
|
|
21
|
+
* @param {boolean} [config.directCall=false] - Use direct Express calls instead of HTTP
|
|
22
|
+
* @param {Object} [config.logger] - Logger instance
|
|
23
|
+
* @param {number} [config.port=3000] - Service port for direct calls
|
|
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
|
+
this.openApiSpec = config.openApiSpec;
|
|
37
|
+
this.serviceUrl = config.serviceUrl || `http://localhost:${config.port || 3000}`;
|
|
38
|
+
this.service = config.service; // Express app for direct calls
|
|
39
|
+
this.directCall = config.directCall === true;
|
|
40
|
+
this.logger = config.logger || console;
|
|
41
|
+
|
|
42
|
+
// Parse OpenAPI to create operation map
|
|
43
|
+
this.operations = this._parseOpenApiSpec(this.openApiSpec);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Load OpenAPI specification
|
|
48
|
+
* @method loadOpenApiSpec
|
|
49
|
+
* @param {Object|string} spec - OpenAPI specification or path
|
|
50
|
+
* @returns {Object} Parsed operations map
|
|
51
|
+
*/
|
|
52
|
+
loadOpenApiSpec(spec) {
|
|
53
|
+
this.openApiSpec = spec;
|
|
54
|
+
this.operations = this._parseOpenApiSpec(spec);
|
|
55
|
+
return this.operations;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Map operation name to HTTP endpoint details
|
|
60
|
+
* @method mapOperationToEndpoint
|
|
61
|
+
* @param {string} operationName - Operation ID from cookbook
|
|
62
|
+
* @returns {Object} Endpoint details {method, path, parameters}
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* const endpoint = apiMapper.mapOperationToEndpoint('getUser');
|
|
66
|
+
* // Returns: {method: 'GET', path: '/users/{id}', parameters: [...]}
|
|
67
|
+
*/
|
|
68
|
+
mapOperationToEndpoint(operationName) {
|
|
69
|
+
const operation = this.operations[operationName];
|
|
70
|
+
if (!operation) {
|
|
71
|
+
throw new Error(`Operation not found: ${operationName}`);
|
|
72
|
+
}
|
|
73
|
+
return operation;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Transform cookbook parameters to HTTP request
|
|
78
|
+
* @method transformRequest
|
|
79
|
+
* @param {Object} cookbookParams - Parameters from cookbook step
|
|
80
|
+
* @param {Object} openApiParams - OpenAPI parameter definitions
|
|
81
|
+
* @returns {Object} Transformed request {params, query, body, headers}
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* const request = apiMapper.transformRequest(
|
|
85
|
+
* {userId: '123', name: 'John'},
|
|
86
|
+
* operation.parameters
|
|
87
|
+
* );
|
|
88
|
+
*/
|
|
89
|
+
transformRequest(cookbookParams, openApiParams) {
|
|
90
|
+
const result = {
|
|
91
|
+
params: {},
|
|
92
|
+
query: {},
|
|
93
|
+
body: null,
|
|
94
|
+
headers: {}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Process each parameter according to OpenAPI spec
|
|
98
|
+
openApiParams.forEach(param => {
|
|
99
|
+
const value = cookbookParams[param.name];
|
|
100
|
+
if (value !== undefined) {
|
|
101
|
+
switch (param.in) {
|
|
102
|
+
case 'path':
|
|
103
|
+
result.params[param.name] = value;
|
|
104
|
+
break;
|
|
105
|
+
case 'query':
|
|
106
|
+
result.query[param.name] = value;
|
|
107
|
+
break;
|
|
108
|
+
case 'header':
|
|
109
|
+
result.headers[param.name] = value;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
} else if (param.required) {
|
|
113
|
+
throw new Error(`Required parameter missing: ${param.name}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Handle request body
|
|
118
|
+
if (cookbookParams.body) {
|
|
119
|
+
result.body = cookbookParams.body;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Transform HTTP response to cookbook format
|
|
127
|
+
* @method transformResponse
|
|
128
|
+
* @param {Object} httpResponse - HTTP response
|
|
129
|
+
* @returns {Object} Transformed response for cookbook
|
|
130
|
+
*/
|
|
131
|
+
transformResponse(httpResponse) {
|
|
132
|
+
// Extract relevant data from HTTP response
|
|
133
|
+
if (httpResponse.data) {
|
|
134
|
+
return httpResponse.data;
|
|
135
|
+
}
|
|
136
|
+
return httpResponse;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Call operation with parameters
|
|
141
|
+
* @async
|
|
142
|
+
* @method callOperation
|
|
143
|
+
* @param {string} operationId - Operation identifier
|
|
144
|
+
* @param {Object} input - Input data for the operation
|
|
145
|
+
* @param {Object} context - Workflow context
|
|
146
|
+
* @returns {Promise<Object>} API response
|
|
147
|
+
*
|
|
148
|
+
* @example
|
|
149
|
+
* const result = await apiMapper.callOperation('getUser', {id: '123'}, context);
|
|
150
|
+
*/
|
|
151
|
+
async callOperation(operationId, input = {}, context = {}) {
|
|
152
|
+
const operation = this.operations[operationId];
|
|
153
|
+
|
|
154
|
+
if (!operation) {
|
|
155
|
+
throw new Error(`Operation not found: ${operationId}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
// Resolve variables in input using context
|
|
160
|
+
const resolvedInput = this._resolveVariables(input, context);
|
|
161
|
+
|
|
162
|
+
// Build request
|
|
163
|
+
const request = this._buildRequest(operation, resolvedInput);
|
|
164
|
+
|
|
165
|
+
// Make the call
|
|
166
|
+
let response;
|
|
167
|
+
if (this.service && this.directCall) {
|
|
168
|
+
response = await this._callDirectly(request);
|
|
169
|
+
} else {
|
|
170
|
+
response = await this._callViaHttp(request);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Transform and return response
|
|
174
|
+
return this.transformResponse(response);
|
|
175
|
+
|
|
176
|
+
} catch (error) {
|
|
177
|
+
this.logger.error(`API call failed for ${operationId}`, {
|
|
178
|
+
error: error.message,
|
|
179
|
+
input,
|
|
180
|
+
operation
|
|
181
|
+
});
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Parse OpenAPI specification to extract operations
|
|
188
|
+
* @private
|
|
189
|
+
* @param {Object} spec - OpenAPI specification
|
|
190
|
+
* @returns {Object} Operations map
|
|
191
|
+
*/
|
|
192
|
+
_parseOpenApiSpec(spec) {
|
|
193
|
+
const operations = {};
|
|
194
|
+
|
|
195
|
+
if (!spec || !spec.paths) {
|
|
196
|
+
return operations;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Extract all operations from OpenAPI paths
|
|
200
|
+
Object.entries(spec.paths).forEach(([path, pathItem]) => {
|
|
201
|
+
Object.entries(pathItem).forEach(([method, operation]) => {
|
|
202
|
+
if (['get', 'post', 'put', 'patch', 'delete'].includes(method)) {
|
|
203
|
+
const operationId = operation.operationId ||
|
|
204
|
+
`${method}_${path.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
205
|
+
|
|
206
|
+
operations[operationId] = {
|
|
207
|
+
method: method.toUpperCase(),
|
|
208
|
+
path,
|
|
209
|
+
parameters: operation.parameters || [],
|
|
210
|
+
requestBody: operation.requestBody,
|
|
211
|
+
responses: operation.responses,
|
|
212
|
+
summary: operation.summary,
|
|
213
|
+
description: operation.description
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
this.logger.info(`Parsed ${Object.keys(operations).length} operations from OpenAPI spec`);
|
|
220
|
+
return operations;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Resolve variables in input using context
|
|
225
|
+
* @private
|
|
226
|
+
* @param {Object} input - Input with potential variable references
|
|
227
|
+
* @param {Object} context - Context containing variable values
|
|
228
|
+
* @returns {Object} Resolved input
|
|
229
|
+
*/
|
|
230
|
+
_resolveVariables(input, context) {
|
|
231
|
+
if (!input || typeof input !== 'object') {
|
|
232
|
+
return input;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const resolved = {};
|
|
236
|
+
|
|
237
|
+
Object.entries(input).forEach(([key, value]) => {
|
|
238
|
+
if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
|
|
239
|
+
// Variable reference: ${context.path.to.value}
|
|
240
|
+
const path = value.slice(2, -1);
|
|
241
|
+
resolved[key] = this._getValueFromPath(context, path);
|
|
242
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
243
|
+
// Recursive resolution
|
|
244
|
+
resolved[key] = this._resolveVariables(value, context);
|
|
245
|
+
} else {
|
|
246
|
+
resolved[key] = value;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return resolved;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get value from object using dot notation path
|
|
255
|
+
* @private
|
|
256
|
+
* @param {Object} obj - Object to search
|
|
257
|
+
* @param {string} path - Dot notation path
|
|
258
|
+
* @returns {*} Value at path
|
|
259
|
+
*/
|
|
260
|
+
_getValueFromPath(obj, path) {
|
|
261
|
+
const parts = path.split('.');
|
|
262
|
+
let current = obj;
|
|
263
|
+
|
|
264
|
+
for (const part of parts) {
|
|
265
|
+
if (current && typeof current === 'object' && part in current) {
|
|
266
|
+
current = current[part];
|
|
267
|
+
} else {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return current;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build HTTP request from operation and input
|
|
277
|
+
* @private
|
|
278
|
+
* @param {Object} operation - Operation definition
|
|
279
|
+
* @param {Object} input - Resolved input
|
|
280
|
+
* @returns {Object} Request object
|
|
281
|
+
*/
|
|
282
|
+
_buildRequest(operation, input) {
|
|
283
|
+
const request = {
|
|
284
|
+
method: operation.method,
|
|
285
|
+
url: this.serviceUrl + operation.path,
|
|
286
|
+
params: {},
|
|
287
|
+
headers: {},
|
|
288
|
+
data: null
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// Process path parameters
|
|
292
|
+
operation.parameters.forEach(param => {
|
|
293
|
+
const value = input[param.name];
|
|
294
|
+
|
|
295
|
+
if (param.in === 'path') {
|
|
296
|
+
// Replace path parameter in URL
|
|
297
|
+
request.url = request.url.replace(`{${param.name}}`, value);
|
|
298
|
+
} else if (param.in === 'query') {
|
|
299
|
+
request.params[param.name] = value;
|
|
300
|
+
} else if (param.in === 'header') {
|
|
301
|
+
request.headers[param.name] = value;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Add request body if present
|
|
306
|
+
if (operation.requestBody && input.body) {
|
|
307
|
+
request.data = input.body;
|
|
308
|
+
request.headers['Content-Type'] = 'application/json';
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return request;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Call service directly via Express app
|
|
316
|
+
* @private
|
|
317
|
+
* @async
|
|
318
|
+
* @param {Object} request - Request object
|
|
319
|
+
* @returns {Promise<Object>} Response
|
|
320
|
+
*/
|
|
321
|
+
async _callDirectly(request) {
|
|
322
|
+
// Create mock req/res objects for Express
|
|
323
|
+
const mockReq = {
|
|
324
|
+
method: request.method,
|
|
325
|
+
url: request.url.replace(this.serviceUrl, ''),
|
|
326
|
+
params: {},
|
|
327
|
+
query: request.params,
|
|
328
|
+
body: request.data,
|
|
329
|
+
headers: request.headers
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Extract path params from URL
|
|
333
|
+
const pathMatch = mockReq.url.match(/\/[^?]*/);
|
|
334
|
+
if (pathMatch) {
|
|
335
|
+
mockReq.path = pathMatch[0];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return new Promise((resolve, reject) => {
|
|
339
|
+
const mockRes = {
|
|
340
|
+
status: function(code) {
|
|
341
|
+
this.statusCode = code;
|
|
342
|
+
return this;
|
|
343
|
+
},
|
|
344
|
+
json: function(data) {
|
|
345
|
+
resolve({ data, status: this.statusCode || 200 });
|
|
346
|
+
},
|
|
347
|
+
send: function(data) {
|
|
348
|
+
resolve({ data, status: this.statusCode || 200 });
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
// Call Express app directly
|
|
353
|
+
this.service(mockReq, mockRes, (err) => {
|
|
354
|
+
if (err) reject(err);
|
|
355
|
+
else reject(new Error('Route not found'));
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Call service via HTTP
|
|
362
|
+
* @private
|
|
363
|
+
* @async
|
|
364
|
+
* @param {Object} request - Request object
|
|
365
|
+
* @returns {Promise<Object>} Response
|
|
366
|
+
*/
|
|
367
|
+
async _callViaHttp(request) {
|
|
368
|
+
try {
|
|
369
|
+
const response = await axios(request);
|
|
370
|
+
return response;
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (error.response) {
|
|
373
|
+
// Server responded with error
|
|
374
|
+
throw new Error(`API returned ${error.response.status}: ${error.response.statusText}`);
|
|
375
|
+
} else if (error.request) {
|
|
376
|
+
// Request made but no response
|
|
377
|
+
throw new Error(`No response from service: ${request.url}`);
|
|
378
|
+
} else {
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = ApiMapper;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* @see {@link https://github.com/onlineapps/oa-drive/tree/main/shared/connector/conn-orch-api-mapper|GitHub Repository}
|
|
9
|
+
* @author OA Drive Team
|
|
10
|
+
* @license MIT
|
|
11
|
+
* @since 1.0.0
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const ApiMapper = require('./ApiMapper');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create API mapper instance
|
|
18
|
+
* @function create
|
|
19
|
+
* @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
|
|
22
|
+
* @param {Object} [config.service] - Express app instance for direct calls
|
|
23
|
+
* @param {boolean} [config.directCall=false] - Use direct Express calls instead of HTTP
|
|
24
|
+
* @param {Object} [config.logger] - Logger instance
|
|
25
|
+
* @returns {ApiMapper} New API mapper instance
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* const apiMapper = create({
|
|
29
|
+
* openApiSpec: require('./openapi.json'),
|
|
30
|
+
* serviceUrl: 'http://localhost:3000'
|
|
31
|
+
* });
|
|
32
|
+
*/
|
|
33
|
+
function create(config) {
|
|
34
|
+
return new ApiMapper(config);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
ApiMapper,
|
|
39
|
+
create,
|
|
40
|
+
VERSION: '1.0.0'
|
|
41
|
+
};
|