@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.
@@ -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
+ };