@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 +9 -9
- package/README.md +2 -2
- package/package.json +2 -3
- package/src/ApiMapper.js +18 -904
- package/src/index.js +11 -8
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="#
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
151
|
+
operations: require('./openapi.json'),
|
|
152
152
|
serviceUrl: 'http://hello-service:3000'
|
|
153
153
|
});
|
|
154
154
|
```
|
|
155
|
-
<a name="
|
|
155
|
+
<a name="loadOperations"></a>
|
|
156
156
|
|
|
157
|
-
##
|
|
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
|
|
234
|
+
// Generate request from operations spec
|
|
235
235
|
const request = await mapper.fromOpenAPI({
|
|
236
|
-
spec:
|
|
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": "
|
|
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
|
-
"
|
|
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
|
-
* @
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
12
|
-
*
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
*
|
|
5
|
+
* @description API mapping connector that maps cookbook operations to HTTP endpoints
|
|
6
|
+
* using the operations.json contract (primary format).
|
|
7
7
|
*
|
|
8
|
-
* @see
|
|
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
|
|
21
|
-
*
|
|
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}
|
|
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
|
-
*
|
|
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) {
|