@onlineapps/service-wrapper 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 +336 -0
- package/README.md +185 -0
- package/build-docs.js +54 -0
- package/docs/PERFORMANCE.md +453 -0
- package/docs/PROCESS_FLOWS.md +389 -0
- package/jest.config.js +34 -0
- package/jsdoc.json +22 -0
- package/package.json +44 -0
- package/src/ServiceWrapper.js +343 -0
- package/src/index.js +23 -0
- package/test/component/ServiceWrapper.component.test.js +407 -0
- package/test/component/connector-integration.test.js +293 -0
- package/test/integration/orchestrator-integration.test.js +170 -0
- package/test/mocks/connectors.js +304 -0
- package/test/run-tests.js +135 -0
- package/test/setup.js +31 -0
- package/test/unit/ServiceWrapper.test.js +372 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# Service Wrapper - Process Flows
|
|
2
|
+
|
|
3
|
+
## Complete Message Processing Flow
|
|
4
|
+
|
|
5
|
+
### 1. Message Arrival
|
|
6
|
+
```javascript
|
|
7
|
+
// Message structure from RabbitMQ
|
|
8
|
+
{
|
|
9
|
+
properties: {
|
|
10
|
+
headers: {
|
|
11
|
+
workflowId: 'wf-550e8400-e29b',
|
|
12
|
+
spanId: 'span-7d3f-4b2a',
|
|
13
|
+
parentSpanId: 'span-6c2e-3a1b',
|
|
14
|
+
userId: 'user-456',
|
|
15
|
+
tenantId: 'tenant-789',
|
|
16
|
+
priority: 'normal'
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
content: Buffer.from(JSON.stringify({
|
|
20
|
+
cookbook: { /* workflow definition */ },
|
|
21
|
+
current_step: 2,
|
|
22
|
+
context: { /* accumulated results */ }
|
|
23
|
+
}))
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 2. Context Extraction
|
|
28
|
+
```javascript
|
|
29
|
+
// Extract correlation context from headers
|
|
30
|
+
const context = {
|
|
31
|
+
workflowId: msg.properties.headers.workflowId,
|
|
32
|
+
spanId: msg.properties.headers.spanId,
|
|
33
|
+
parentSpanId: msg.properties.headers.parentSpanId,
|
|
34
|
+
userId: msg.properties.headers.userId
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Set context in logger for all subsequent operations
|
|
38
|
+
logger.setContext(context);
|
|
39
|
+
logger.info('message.received', { queue: 'hello-service.workflow' });
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 3. Cookbook Processing
|
|
43
|
+
```javascript
|
|
44
|
+
// Parse message body
|
|
45
|
+
const body = JSON.parse(msg.content.toString());
|
|
46
|
+
const cookbook = body.cookbook;
|
|
47
|
+
const currentStep = cookbook.steps[body.current_step];
|
|
48
|
+
|
|
49
|
+
// Check if this service should handle this step
|
|
50
|
+
if (currentStep.service !== this.serviceName) {
|
|
51
|
+
// Route to correct service
|
|
52
|
+
const targetQueue = `${currentStep.service}.workflow`;
|
|
53
|
+
await mqClient.publish(targetQueue, msg);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### 4. Cache Check
|
|
59
|
+
```javascript
|
|
60
|
+
// Generate cache key from operation and parameters
|
|
61
|
+
const cacheKey = generateCacheKey(currentStep);
|
|
62
|
+
// Format: "cache:api:hello-service:sayGoodDay:hash(John)"
|
|
63
|
+
|
|
64
|
+
// Check cache
|
|
65
|
+
const startTime = Date.now();
|
|
66
|
+
const cached = await cache.get(cacheKey);
|
|
67
|
+
|
|
68
|
+
if (cached) {
|
|
69
|
+
logger.info('cache.hit', {
|
|
70
|
+
key: cacheKey,
|
|
71
|
+
latency: Date.now() - startTime
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Skip API call, use cached result
|
|
75
|
+
return processNextStep(cached);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
logger.info('cache.miss', { key: cacheKey });
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### 5. API Call Mapping
|
|
82
|
+
```javascript
|
|
83
|
+
// Map cookbook operation to HTTP endpoint
|
|
84
|
+
const endpoint = mapOperationToEndpoint(currentStep.operation);
|
|
85
|
+
// Example: "sayGoodDay" → GET /good-day
|
|
86
|
+
|
|
87
|
+
// Transform parameters
|
|
88
|
+
const httpParams = transformParameters(currentStep.params);
|
|
89
|
+
// Example: {name: "John"} → ?name=John
|
|
90
|
+
|
|
91
|
+
// Make HTTP call to service
|
|
92
|
+
const apiStart = Date.now();
|
|
93
|
+
const response = await axios({
|
|
94
|
+
method: endpoint.method,
|
|
95
|
+
url: `${this.serviceUrl}${endpoint.path}`,
|
|
96
|
+
params: httpParams
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
logger.info('api.call.completed', {
|
|
100
|
+
operation: currentStep.operation,
|
|
101
|
+
latency: Date.now() - apiStart,
|
|
102
|
+
status: response.status
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### 6. Cache Storage
|
|
107
|
+
```javascript
|
|
108
|
+
// Store successful response in cache
|
|
109
|
+
if (response.status === 200) {
|
|
110
|
+
await cache.set(cacheKey, response.data, {
|
|
111
|
+
ttl: this.config.cache.ttl || 300 // 5 minutes default
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
logger.info('cache.stored', {
|
|
115
|
+
key: cacheKey,
|
|
116
|
+
ttl: 300
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### 7. Context Update
|
|
122
|
+
```javascript
|
|
123
|
+
// Add result to workflow context
|
|
124
|
+
body.context.results = body.context.results || {};
|
|
125
|
+
body.context.results[currentStep.id] = response.data;
|
|
126
|
+
|
|
127
|
+
// Update step index
|
|
128
|
+
body.current_step++;
|
|
129
|
+
|
|
130
|
+
// Add trace information
|
|
131
|
+
body.trace = body.trace || [];
|
|
132
|
+
body.trace.push({
|
|
133
|
+
step: body.current_step - 1,
|
|
134
|
+
service: this.serviceName,
|
|
135
|
+
timestamp: new Date().toISOString(),
|
|
136
|
+
duration: Date.now() - startTime
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 8. Next Step Routing
|
|
141
|
+
```javascript
|
|
142
|
+
// Determine next action
|
|
143
|
+
if (body.current_step >= cookbook.steps.length) {
|
|
144
|
+
// Workflow complete
|
|
145
|
+
await mqClient.publish('workflow.completed', {
|
|
146
|
+
headers: msg.properties.headers,
|
|
147
|
+
body: body
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
logger.info('workflow.completed', {
|
|
151
|
+
workflowId: context.workflowId,
|
|
152
|
+
totalSteps: cookbook.steps.length
|
|
153
|
+
});
|
|
154
|
+
} else {
|
|
155
|
+
// Route to next service
|
|
156
|
+
const nextStep = cookbook.steps[body.current_step];
|
|
157
|
+
const nextQueue = `${nextStep.service}.workflow`;
|
|
158
|
+
|
|
159
|
+
// Create new span for next step
|
|
160
|
+
const newHeaders = {
|
|
161
|
+
...msg.properties.headers,
|
|
162
|
+
parentSpanId: context.spanId,
|
|
163
|
+
spanId: generateSpanId()
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
await mqClient.publish(nextQueue, {
|
|
167
|
+
headers: newHeaders,
|
|
168
|
+
body: body
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
logger.info('message.routed', {
|
|
172
|
+
nextQueue,
|
|
173
|
+
nextService: nextStep.service
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Fork-Join Flow
|
|
179
|
+
|
|
180
|
+
### 1. Fork Detection
|
|
181
|
+
```javascript
|
|
182
|
+
if (currentStep.type === 'fork_join') {
|
|
183
|
+
const branches = currentStep.branches;
|
|
184
|
+
const accumulatorQueue = `fork.${context.workflowId}.${currentStep.id}`;
|
|
185
|
+
|
|
186
|
+
// Create accumulator metadata
|
|
187
|
+
const metadata = {
|
|
188
|
+
expectedResults: branches.length,
|
|
189
|
+
receivedResults: 0,
|
|
190
|
+
results: {}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
await cache.set(`fork:${accumulatorQueue}`, metadata, { ttl: 300 });
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### 2. Branch Distribution
|
|
197
|
+
```javascript
|
|
198
|
+
// Send to each branch
|
|
199
|
+
for (const branch of branches) {
|
|
200
|
+
const branchMessage = {
|
|
201
|
+
...body,
|
|
202
|
+
current_step: 0,
|
|
203
|
+
cookbook: branch,
|
|
204
|
+
fork_metadata: {
|
|
205
|
+
accumulatorQueue,
|
|
206
|
+
branchId: branch.id
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const firstService = branch.steps[0].service;
|
|
211
|
+
await mqClient.publish(`${firstService}.workflow`, {
|
|
212
|
+
headers: {
|
|
213
|
+
...headers,
|
|
214
|
+
parentSpanId: context.spanId,
|
|
215
|
+
spanId: generateSpanId()
|
|
216
|
+
},
|
|
217
|
+
body: branchMessage
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### 3. Result Collection
|
|
224
|
+
```javascript
|
|
225
|
+
// When branch completes, it sends to accumulator
|
|
226
|
+
if (body.fork_metadata) {
|
|
227
|
+
const { accumulatorQueue, branchId } = body.fork_metadata;
|
|
228
|
+
|
|
229
|
+
// Update accumulator
|
|
230
|
+
const metadata = await cache.get(`fork:${accumulatorQueue}`);
|
|
231
|
+
metadata.results[branchId] = body.context.results;
|
|
232
|
+
metadata.receivedResults++;
|
|
233
|
+
|
|
234
|
+
if (metadata.receivedResults === metadata.expectedResults) {
|
|
235
|
+
// All branches complete - join results
|
|
236
|
+
const joinedResults = joinStrategy(metadata.results);
|
|
237
|
+
|
|
238
|
+
// Continue main workflow
|
|
239
|
+
const mainMessage = {
|
|
240
|
+
...originalBody,
|
|
241
|
+
context: {
|
|
242
|
+
...originalBody.context,
|
|
243
|
+
results: {
|
|
244
|
+
...originalBody.context.results,
|
|
245
|
+
[currentStep.id]: joinedResults
|
|
246
|
+
}
|
|
247
|
+
},
|
|
248
|
+
current_step: originalBody.current_step + 1
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Route to next step in main workflow
|
|
252
|
+
routeToNext(mainMessage);
|
|
253
|
+
} else {
|
|
254
|
+
// Save updated metadata and wait for more results
|
|
255
|
+
await cache.set(`fork:${accumulatorQueue}`, metadata);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Error Handling Flows
|
|
261
|
+
|
|
262
|
+
### 1. Transient Error (Retry)
|
|
263
|
+
```javascript
|
|
264
|
+
try {
|
|
265
|
+
const response = await axios.get(url);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (isTransientError(error)) {
|
|
268
|
+
// Network timeout, 503, etc.
|
|
269
|
+
message.retry_count = (message.retry_count || 0) + 1;
|
|
270
|
+
|
|
271
|
+
if (message.retry_count <= 3) {
|
|
272
|
+
// Retry with exponential backoff
|
|
273
|
+
const delay = Math.pow(2, message.retry_count) * 1000;
|
|
274
|
+
|
|
275
|
+
setTimeout(() => {
|
|
276
|
+
mqClient.publish(currentQueue, message);
|
|
277
|
+
}, delay);
|
|
278
|
+
|
|
279
|
+
logger.warn('message.retry', {
|
|
280
|
+
attempt: message.retry_count,
|
|
281
|
+
delay,
|
|
282
|
+
error: error.message
|
|
283
|
+
});
|
|
284
|
+
} else {
|
|
285
|
+
// Max retries exceeded - send to DLQ
|
|
286
|
+
await mqClient.publish(`${currentQueue}.dlq`, message);
|
|
287
|
+
logger.error('message.dlq', {
|
|
288
|
+
reason: 'max_retries',
|
|
289
|
+
attempts: message.retry_count
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### 2. Business Error (Continue)
|
|
297
|
+
```javascript
|
|
298
|
+
if (error.response && error.response.status === 400) {
|
|
299
|
+
// Business validation error - include in workflow
|
|
300
|
+
body.context.results[currentStep.id] = {
|
|
301
|
+
error: true,
|
|
302
|
+
message: error.response.data.message,
|
|
303
|
+
code: 'VALIDATION_ERROR'
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// Continue to next step
|
|
307
|
+
body.current_step++;
|
|
308
|
+
routeToNext(body);
|
|
309
|
+
|
|
310
|
+
logger.info('business.error', {
|
|
311
|
+
step: currentStep.id,
|
|
312
|
+
error: error.response.data.message
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### 3. Fatal Error (Stop)
|
|
318
|
+
```javascript
|
|
319
|
+
if (error.code === 'SERVICE_NOT_FOUND') {
|
|
320
|
+
// Service doesn't exist - can't continue
|
|
321
|
+
await mqClient.publish('workflow.failed', {
|
|
322
|
+
headers: context,
|
|
323
|
+
body: {
|
|
324
|
+
...body,
|
|
325
|
+
error: {
|
|
326
|
+
step: currentStep.id,
|
|
327
|
+
service: currentStep.service,
|
|
328
|
+
message: 'Service not found in registry'
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
logger.error('workflow.failed', {
|
|
334
|
+
workflowId: context.workflowId,
|
|
335
|
+
reason: 'service_not_found',
|
|
336
|
+
service: currentStep.service
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
## Performance Tracking
|
|
342
|
+
|
|
343
|
+
### Metrics Collection
|
|
344
|
+
```javascript
|
|
345
|
+
class PerformanceTracker {
|
|
346
|
+
constructor() {
|
|
347
|
+
this.metrics = {
|
|
348
|
+
queue_wait: 0,
|
|
349
|
+
cache_check: 0,
|
|
350
|
+
api_call: 0,
|
|
351
|
+
cache_store: 0,
|
|
352
|
+
routing: 0,
|
|
353
|
+
total: 0
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async trackOperation(name, fn) {
|
|
358
|
+
const start = Date.now();
|
|
359
|
+
try {
|
|
360
|
+
const result = await fn();
|
|
361
|
+
this.metrics[name] = Date.now() - start;
|
|
362
|
+
return result;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
this.metrics[name] = Date.now() - start;
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
getMetrics() {
|
|
370
|
+
this.metrics.total = Object.values(this.metrics)
|
|
371
|
+
.reduce((sum, val) => sum + val, 0);
|
|
372
|
+
return this.metrics;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Usage in message processing
|
|
377
|
+
const tracker = new PerformanceTracker();
|
|
378
|
+
|
|
379
|
+
const cached = await tracker.trackOperation('cache_check',
|
|
380
|
+
() => cache.get(cacheKey));
|
|
381
|
+
|
|
382
|
+
const response = await tracker.trackOperation('api_call',
|
|
383
|
+
() => axios.get(url));
|
|
384
|
+
|
|
385
|
+
logger.info('performance.metrics', tracker.getMetrics());
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
*For configuration and setup, see main [Service Wrapper documentation](../README.md)*
|
package/jest.config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
testEnvironment: 'node',
|
|
3
|
+
coverageDirectory: 'coverage',
|
|
4
|
+
collectCoverageFrom: [
|
|
5
|
+
'src/**/*.js',
|
|
6
|
+
'!src/**/*.test.js',
|
|
7
|
+
'!src/**/index.js'
|
|
8
|
+
],
|
|
9
|
+
testMatch: [
|
|
10
|
+
'**/test/**/*.test.js'
|
|
11
|
+
],
|
|
12
|
+
testPathIgnorePatterns: [
|
|
13
|
+
'/node_modules/'
|
|
14
|
+
],
|
|
15
|
+
coverageThresholds: {
|
|
16
|
+
global: {
|
|
17
|
+
branches: 80,
|
|
18
|
+
functions: 80,
|
|
19
|
+
lines: 80,
|
|
20
|
+
statements: 80
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
moduleNameMapper: {
|
|
24
|
+
// Map connector imports to mocks in unit tests
|
|
25
|
+
'@onlineapps/conn-infra-mq': '<rootDir>/test/mocks/connectors.js',
|
|
26
|
+
'@onlineapps/conn-orch-registry': '<rootDir>/test/mocks/connectors.js',
|
|
27
|
+
'@onlineapps/conn-base-logger': '<rootDir>/test/mocks/connectors.js',
|
|
28
|
+
'@onlineapps/conn-orch-orchestrator': '<rootDir>/test/mocks/connectors.js',
|
|
29
|
+
'@onlineapps/conn-orch-api-mapper': '<rootDir>/test/mocks/connectors.js',
|
|
30
|
+
'@onlineapps/conn-orch-cookbook': '<rootDir>/test/mocks/connectors.js'
|
|
31
|
+
},
|
|
32
|
+
setupFilesAfterEnv: ['<rootDir>/test/setup.js'],
|
|
33
|
+
verbose: true
|
|
34
|
+
};
|
package/jsdoc.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"source": {
|
|
3
|
+
"include": ["src/"],
|
|
4
|
+
"includePattern": ".js$",
|
|
5
|
+
"excludePattern": "(node_modules/|docs/|test/)"
|
|
6
|
+
},
|
|
7
|
+
"plugins": ["plugins/markdown"],
|
|
8
|
+
"templates": {
|
|
9
|
+
"cleverLinks": false,
|
|
10
|
+
"monospaceLinks": false,
|
|
11
|
+
"default": {
|
|
12
|
+
"outputSourceFiles": true,
|
|
13
|
+
"includeDate": false
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"opts": {
|
|
17
|
+
"destination": "docs/html",
|
|
18
|
+
"recurse": true,
|
|
19
|
+
"readme": "README.md",
|
|
20
|
+
"package": "package.json"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onlineapps/service-wrapper",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Thin orchestration layer for microservices - delegates all infrastructure concerns to specialized connectors",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "jest",
|
|
8
|
+
"test:unit": "jest test/unit",
|
|
9
|
+
"test:component": "jest test/component",
|
|
10
|
+
"test:integration": "jest test/integration",
|
|
11
|
+
"test:coverage": "jest --coverage",
|
|
12
|
+
"test:mocked": "node test/run-tests.js",
|
|
13
|
+
"docs": "jsdoc2md --files src/**/*.js > API.md",
|
|
14
|
+
"docs:html": "jsdoc -c jsdoc.json -d docs/html"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"microservices",
|
|
18
|
+
"workflow",
|
|
19
|
+
"infrastructure",
|
|
20
|
+
"wrapper",
|
|
21
|
+
"thin-layer",
|
|
22
|
+
"orchestration"
|
|
23
|
+
],
|
|
24
|
+
"author": "OA Drive Team",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@onlineapps/conn-base-logger": "^1.0.0",
|
|
28
|
+
"@onlineapps/conn-infra-mq": "^1.1.0",
|
|
29
|
+
"@onlineapps/conn-orch-registry": "^1.1.4",
|
|
30
|
+
"@onlineapps/conn-orch-cookbook": "^2.0.0",
|
|
31
|
+
"@onlineapps/conn-orch-orchestrator": "^1.0.0",
|
|
32
|
+
"@onlineapps/conn-orch-api-mapper": "^1.0.0",
|
|
33
|
+
"@onlineapps/conn-base-cache": "^1.0.0",
|
|
34
|
+
"@onlineapps/conn-infra-error-handler": "^1.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"jest": "^29.5.0",
|
|
38
|
+
"jsdoc": "^4.0.2",
|
|
39
|
+
"jsdoc-to-markdown": "^8.0.0"
|
|
40
|
+
},
|
|
41
|
+
"engines": {
|
|
42
|
+
"node": ">=14.0.0"
|
|
43
|
+
}
|
|
44
|
+
}
|