@onlineapps/service-wrapper 2.4.8 → 3.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/package.json +4 -3
- package/src/ContextBuilder.js +244 -0
- package/src/ErrorMapper.js +162 -0
- package/src/HandlerLoader.js +94 -0
- package/src/HandlerRegistry.js +114 -0
- package/src/OperationContext.js +59 -0
- package/src/SchemaValidator.js +183 -0
- package/src/ServiceWrapper.js +250 -23
- package/src/index.js +32 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/service-wrapper",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Thin orchestration layer for microservices - delegates all infrastructure concerns to specialized connectors",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -29,14 +29,15 @@
|
|
|
29
29
|
"@onlineapps/conn-base-monitoring": "1.0.12",
|
|
30
30
|
"@onlineapps/conn-infra-error-handler": "1.0.11",
|
|
31
31
|
"@onlineapps/conn-infra-mq": "1.1.70",
|
|
32
|
-
"@onlineapps/conn-orch-api-mapper": "1.0.34",
|
|
33
32
|
"@onlineapps/conn-orch-cookbook": "2.1.2",
|
|
34
33
|
"@onlineapps/conn-orch-orchestrator": "1.0.115",
|
|
35
34
|
"@onlineapps/conn-orch-registry": "1.2.1",
|
|
36
35
|
"@onlineapps/conn-orch-validator": "2.0.34",
|
|
37
36
|
"@onlineapps/monitoring-core": "1.0.23",
|
|
38
37
|
"@onlineapps/service-common": "1.1.2",
|
|
39
|
-
"@onlineapps/runtime-config": "1.0.2"
|
|
38
|
+
"@onlineapps/runtime-config": "1.0.2",
|
|
39
|
+
"ajv": "8.17.1",
|
|
40
|
+
"ajv-formats": "3.0.1"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
42
43
|
"express": "^5.1.0",
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { OperationContext } = require('./OperationContext');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ContextBuilder — assembles an OperationContext for a single invocation.
|
|
7
|
+
*
|
|
8
|
+
* Contract: RFC `api/docs/architecture/biz-service-invocation-model.md` §5.6 + §5.9.
|
|
9
|
+
* Layer: L3 orchestration (see ARCHITECTURE_PRINCIPLES.md §7).
|
|
10
|
+
* - Depends on connectors (L1/L2) via constructor injection only.
|
|
11
|
+
* - Imports `./OperationContext` only. MUST NOT import HandlerRegistry,
|
|
12
|
+
* SchemaValidator, or ErrorMapper.
|
|
13
|
+
* - Does NOT validate handler input/output schemas (SchemaValidator owns that).
|
|
14
|
+
* - Does NOT invoke handlers (ServiceWrapper owns that).
|
|
15
|
+
*
|
|
16
|
+
* SRP: single job — translate (mqMessage, operationSpec) into a ready-to-use
|
|
17
|
+
* OperationContext plus a release() hook the wrapper must call after the
|
|
18
|
+
* handler returns (success or failure).
|
|
19
|
+
*/
|
|
20
|
+
class ContextBuilder {
|
|
21
|
+
/**
|
|
22
|
+
* @param {Object} deps
|
|
23
|
+
* @param {Object} deps.connectors - { storage, cache, http, secrets, monitoring, mq }
|
|
24
|
+
* @param {Object} deps.config - Read-only service config snapshot.
|
|
25
|
+
* @param {Object} deps.logger - Base logger (must expose `.child()` to be useful).
|
|
26
|
+
*/
|
|
27
|
+
constructor({ connectors, config, logger } = {}) {
|
|
28
|
+
if (!connectors) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'[ContextBuilder] connectors is required - Expected { storage, cache, http, secrets, monitoring, mq }'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (!config) {
|
|
34
|
+
throw new Error('[ContextBuilder] config is required - Expected service config object');
|
|
35
|
+
}
|
|
36
|
+
if (!logger) {
|
|
37
|
+
throw new Error('[ContextBuilder] logger is required - Expected base logger');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
this._connectors = connectors;
|
|
41
|
+
this._config = config;
|
|
42
|
+
this._logger = logger;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build an OperationContext for one MQ invocation.
|
|
47
|
+
*
|
|
48
|
+
* Fail-fast order:
|
|
49
|
+
* 1. MQ envelope shape (operation, envelope, workflow_id, correlation_id).
|
|
50
|
+
* 2. Tenancy required-ness from operationSpec.bundle_scope:
|
|
51
|
+
* 'tenant' -> tenant_id required
|
|
52
|
+
* 'workspace' -> tenant_id AND workspace_id required
|
|
53
|
+
* 'platform' -> both optional
|
|
54
|
+
* Unset bundle_scope defaults to 'workspace' (strictest) per RFC §5.6 policy.
|
|
55
|
+
* 3. Scoped logger (via base.child() when available; fall back to base logger
|
|
56
|
+
* only when the base does not implement .child — bunyan/pino both do).
|
|
57
|
+
* 4. Connector acquisition (db, cache, httpClient, secrets facade).
|
|
58
|
+
* 5. stream(chunk) -> mq.publishChunk (TODO P4.x biz-aiclient integration).
|
|
59
|
+
* 6. abortSignal — external mqMessage.abortSignal wins; otherwise a no-op
|
|
60
|
+
* controller (plumbing for cancellation is intentionally out of scope here).
|
|
61
|
+
*
|
|
62
|
+
* @param {Object} mqMessage - Parsed MQ message (envelope, operation, ids).
|
|
63
|
+
* @param {Object} operationSpec - operations.json entry for this op (bundle_scope, ...).
|
|
64
|
+
* @returns {Promise<{ ctx: OperationContext, release: () => Promise<void> }>}
|
|
65
|
+
* Callers MUST await release() in a finally block (see RFC §5.9 step 8).
|
|
66
|
+
* release() is idempotent.
|
|
67
|
+
*/
|
|
68
|
+
async build(mqMessage, operationSpec) {
|
|
69
|
+
this._validateMqMessage(mqMessage);
|
|
70
|
+
if (!operationSpec || typeof operationSpec !== 'object') {
|
|
71
|
+
throw new Error(
|
|
72
|
+
'[ContextBuilder] operationSpec is required - Expected operations.json entry with bundle_scope'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const envelope = mqMessage.envelope;
|
|
77
|
+
const bundleScope = operationSpec.bundle_scope || 'workspace';
|
|
78
|
+
this._validateTenancy(bundleScope, envelope);
|
|
79
|
+
|
|
80
|
+
const scopedLogger = typeof this._logger.child === 'function'
|
|
81
|
+
? this._logger.child({
|
|
82
|
+
operation: mqMessage.operation,
|
|
83
|
+
workflow_id: mqMessage.workflow_id,
|
|
84
|
+
correlation_id: mqMessage.correlation_id,
|
|
85
|
+
tenant_id: envelope.tenant_id,
|
|
86
|
+
workspace_id: envelope.workspace_id
|
|
87
|
+
})
|
|
88
|
+
: this._logger;
|
|
89
|
+
|
|
90
|
+
const { db, releaseDb } = await this._acquireDb();
|
|
91
|
+
const cache = this._connectors.cache;
|
|
92
|
+
const httpClient = this._connectors.http;
|
|
93
|
+
const secretsAdapter = this._connectors.secrets;
|
|
94
|
+
const secrets = {
|
|
95
|
+
get: (name) => {
|
|
96
|
+
if (!secretsAdapter || typeof secretsAdapter.get !== 'function') {
|
|
97
|
+
throw new Error(
|
|
98
|
+
'[ContextBuilder] secrets.get called but connectors.secrets has no get(name) - ' +
|
|
99
|
+
'wire a secrets connector before using ctx.secrets'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return secretsAdapter.get(name);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// TODO(P4.x biz-aiclient): implement real chunk streaming via MQ response-chunk
|
|
107
|
+
// routing once biz-aiclient defines the protocol. Until then, the hook is a
|
|
108
|
+
// no-op so handlers can call ctx.stream(chunk) unconditionally.
|
|
109
|
+
const mq = this._connectors.mq;
|
|
110
|
+
const stream = (chunk) => {
|
|
111
|
+
if (mq && typeof mq.publishChunk === 'function') {
|
|
112
|
+
return mq.publishChunk(mqMessage, chunk);
|
|
113
|
+
}
|
|
114
|
+
return Promise.resolve();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const abortSignal = mqMessage.abortSignal instanceof AbortSignal
|
|
118
|
+
? mqMessage.abortSignal
|
|
119
|
+
: new AbortController().signal;
|
|
120
|
+
|
|
121
|
+
const ctx = new OperationContext({
|
|
122
|
+
tenant_id: envelope.tenant_id ?? null,
|
|
123
|
+
workspace_id: envelope.workspace_id ?? null,
|
|
124
|
+
person_id: envelope.person_id ?? null,
|
|
125
|
+
operation_name: mqMessage.operation,
|
|
126
|
+
workflow_id: mqMessage.workflow_id,
|
|
127
|
+
correlation_id: mqMessage.correlation_id,
|
|
128
|
+
logger: scopedLogger,
|
|
129
|
+
db,
|
|
130
|
+
cache,
|
|
131
|
+
httpClient,
|
|
132
|
+
secrets,
|
|
133
|
+
stream,
|
|
134
|
+
abortSignal,
|
|
135
|
+
config: this._config
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
let released = false;
|
|
139
|
+
const release = async () => {
|
|
140
|
+
if (released) return;
|
|
141
|
+
released = true;
|
|
142
|
+
|
|
143
|
+
if (typeof releaseDb === 'function') {
|
|
144
|
+
await releaseDb();
|
|
145
|
+
}
|
|
146
|
+
if (scopedLogger && typeof scopedLogger.flush === 'function') {
|
|
147
|
+
try {
|
|
148
|
+
await scopedLogger.flush();
|
|
149
|
+
} catch (_) {
|
|
150
|
+
// Logger flush failures must not mask handler errors. The release
|
|
151
|
+
// hook is best-effort for buffered logs; real failures surface via
|
|
152
|
+
// the base logger on the next tick.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
return { ctx, release };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @private
|
|
162
|
+
*/
|
|
163
|
+
_validateMqMessage(mqMessage) {
|
|
164
|
+
if (!mqMessage || typeof mqMessage !== 'object') {
|
|
165
|
+
throw new Error('[ContextBuilder] mqMessage is required - Expected parsed MQ message object');
|
|
166
|
+
}
|
|
167
|
+
if (typeof mqMessage.operation !== 'string' || mqMessage.operation.length === 0) {
|
|
168
|
+
throw new Error('[ContextBuilder] mqMessage.operation is required - Expected non-empty string');
|
|
169
|
+
}
|
|
170
|
+
if (!mqMessage.envelope || typeof mqMessage.envelope !== 'object') {
|
|
171
|
+
throw new Error(
|
|
172
|
+
'[ContextBuilder] mqMessage.envelope is required - ' +
|
|
173
|
+
'Expected object with tenant_id, workspace_id, optional person_id'
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (typeof mqMessage.workflow_id !== 'string' || mqMessage.workflow_id.length === 0) {
|
|
177
|
+
throw new Error('[ContextBuilder] mqMessage.workflow_id is required - Expected uuid string');
|
|
178
|
+
}
|
|
179
|
+
if (typeof mqMessage.correlation_id !== 'string' || mqMessage.correlation_id.length === 0) {
|
|
180
|
+
throw new Error('[ContextBuilder] mqMessage.correlation_id is required - Expected uuid string');
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Enforce bundle_scope tenancy requirements per RFC §5.6 + §6.3 Bug 1 mapping.
|
|
186
|
+
* Tenancy is derived from the MQ envelope only — never from HTTP headers.
|
|
187
|
+
* @private
|
|
188
|
+
*/
|
|
189
|
+
_validateTenancy(bundleScope, envelope) {
|
|
190
|
+
if (bundleScope !== 'tenant' && bundleScope !== 'workspace' && bundleScope !== 'platform') {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`[ContextBuilder] operationSpec.bundle_scope is invalid - got ${JSON.stringify(bundleScope)}, ` +
|
|
193
|
+
'expected one of: "tenant", "workspace", "platform"'
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (bundleScope === 'tenant' || bundleScope === 'workspace') {
|
|
198
|
+
if (envelope.tenant_id === undefined || envelope.tenant_id === null) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`[ContextBuilder] mqMessage.envelope.tenant_id is required for bundle_scope="${bundleScope}" - ` +
|
|
201
|
+
'tenancy must come from the MQ envelope (see RFC §6.3 Bug 1)'
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (bundleScope === 'workspace') {
|
|
206
|
+
if (envelope.workspace_id === undefined || envelope.workspace_id === null) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
'[ContextBuilder] mqMessage.envelope.workspace_id is required for bundle_scope="workspace" - ' +
|
|
209
|
+
'workspace-scoped ops must receive a workspace_id in the MQ envelope'
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Acquire a db client from the storage connector, tolerating the three
|
|
217
|
+
* shapes ServiceWrapper currently exposes:
|
|
218
|
+
* - `storage.acquireClient()` -> Promise<client> (preferred, pool-aware)
|
|
219
|
+
* - `storage.client` (single shared handle)
|
|
220
|
+
* - the connector itself acts as the client
|
|
221
|
+
* Returns the client plus a releaseDb hook used by the release() contract.
|
|
222
|
+
* @private
|
|
223
|
+
* @returns {Promise<{ db: *, releaseDb: (()=>Promise<void>)|null }>}
|
|
224
|
+
*/
|
|
225
|
+
async _acquireDb() {
|
|
226
|
+
const storage = this._connectors.storage;
|
|
227
|
+
if (!storage) {
|
|
228
|
+
return { db: null, releaseDb: null };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (typeof storage.acquireClient === 'function') {
|
|
232
|
+
const db = await storage.acquireClient();
|
|
233
|
+
const releaseDb = typeof storage.releaseClient === 'function'
|
|
234
|
+
? () => storage.releaseClient(db)
|
|
235
|
+
: (typeof db?.release === 'function' ? () => db.release() : null);
|
|
236
|
+
return { db, releaseDb };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const db = storage.client || storage;
|
|
240
|
+
return { db, releaseDb: null };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = { ContextBuilder };
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ErrorMapper - Maps handler exceptions to MQ response envelopes.
|
|
5
|
+
*
|
|
6
|
+
* Contract: see api/docs/architecture/biz-service-invocation-model.md §5.8.
|
|
7
|
+
*
|
|
8
|
+
* Layer L2 (Infrastructure). Stand-alone: no imports from other wrapper
|
|
9
|
+
* modules, no third-party deps. Logger is injected via constructor (DI).
|
|
10
|
+
*
|
|
11
|
+
* Exports:
|
|
12
|
+
* - ErrorMapper — maps exceptions to { status, error: {...} } envelopes.
|
|
13
|
+
* - ValidationError — thrown by SchemaValidator (phase: 'input' | 'output').
|
|
14
|
+
* - UnknownOperationError — thrown by HandlerRegistry for unregistered ops.
|
|
15
|
+
* - BusinessError — base class for handler-authored errors (code + status required).
|
|
16
|
+
* - AbortError — thrown when ctx.abortSignal aborts the invocation.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
class ValidationError extends Error {
|
|
20
|
+
constructor({ message, details, phase, operation } = {}) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = 'ValidationError';
|
|
23
|
+
this.code = 'VALIDATION_FAILED';
|
|
24
|
+
this.status = 400;
|
|
25
|
+
this.details = details;
|
|
26
|
+
this.phase = phase;
|
|
27
|
+
this.operation = operation;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
class UnknownOperationError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'UnknownOperationError';
|
|
35
|
+
this.code = 'UNKNOWN_OPERATION';
|
|
36
|
+
this.status = 404;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class BusinessError extends Error {
|
|
41
|
+
constructor({ code, status, message, details } = {}) {
|
|
42
|
+
if (!code) {
|
|
43
|
+
throw new Error('[BusinessError] code is required - Expected uppercase SNAKE_CASE identifier');
|
|
44
|
+
}
|
|
45
|
+
if (!status || typeof status !== 'number') {
|
|
46
|
+
throw new Error('[BusinessError] status is required - Expected numeric HTTP-equivalent status');
|
|
47
|
+
}
|
|
48
|
+
if (!message) {
|
|
49
|
+
throw new Error('[BusinessError] message is required');
|
|
50
|
+
}
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = 'BusinessError';
|
|
53
|
+
this.code = code;
|
|
54
|
+
this.status = status;
|
|
55
|
+
this.details = details;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
class AbortError extends Error {
|
|
60
|
+
constructor(message) {
|
|
61
|
+
super(message || 'Invocation aborted');
|
|
62
|
+
this.name = 'AbortError';
|
|
63
|
+
this.code = 'CLIENT_CANCELLED';
|
|
64
|
+
this.status = 499;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class ErrorMapper {
|
|
69
|
+
/**
|
|
70
|
+
* @param {Object} options
|
|
71
|
+
* @param {Object} options.logger - Base logger (must expose .info/.warn/.error).
|
|
72
|
+
*/
|
|
73
|
+
constructor({ logger } = {}) {
|
|
74
|
+
if (!logger) {
|
|
75
|
+
throw new Error('[ErrorMapper] logger is required - Expected base logger for internal-error logging');
|
|
76
|
+
}
|
|
77
|
+
this._logger = logger;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Map a handler exception to an MQ response envelope.
|
|
82
|
+
* Never throws. Always returns `{ status, error: { code, message, [details], [correlation_id] } }`.
|
|
83
|
+
*
|
|
84
|
+
* @param {Error} err
|
|
85
|
+
* @param {Object} meta
|
|
86
|
+
* @param {string} [meta.operation]
|
|
87
|
+
* @param {string} [meta.correlation_id]
|
|
88
|
+
* @returns {{ status: number, error: { code: string, message: string, details?: any, correlation_id?: string } }}
|
|
89
|
+
*/
|
|
90
|
+
map(err, meta = {}) {
|
|
91
|
+
const { operation, correlation_id } = meta;
|
|
92
|
+
|
|
93
|
+
if (err instanceof ValidationError) {
|
|
94
|
+
if (err.phase === 'output') {
|
|
95
|
+
this._logger.error(
|
|
96
|
+
{ err, operation, correlation_id, code: 'INTERNAL_ERROR', status: 500 },
|
|
97
|
+
'output schema validation failed - server bug'
|
|
98
|
+
);
|
|
99
|
+
return this._buildError(500, 'INTERNAL_ERROR', 'Internal validation error', undefined, correlation_id);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this._logger.info(
|
|
103
|
+
{ err, operation, correlation_id, code: 'VALIDATION_FAILED', status: 400 },
|
|
104
|
+
'input schema validation failed'
|
|
105
|
+
);
|
|
106
|
+
return this._buildError(400, 'VALIDATION_FAILED', err.message, err.details, correlation_id);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (err instanceof UnknownOperationError) {
|
|
110
|
+
this._logger.warn(
|
|
111
|
+
{ err, operation, correlation_id, code: 'UNKNOWN_OPERATION', status: 404 },
|
|
112
|
+
'unknown operation requested'
|
|
113
|
+
);
|
|
114
|
+
return this._buildError(404, 'UNKNOWN_OPERATION', err.message, undefined, correlation_id);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (err instanceof BusinessError) {
|
|
118
|
+
this._logger.info(
|
|
119
|
+
{ err, operation, correlation_id, code: err.code, status: err.status },
|
|
120
|
+
'handler raised BusinessError'
|
|
121
|
+
);
|
|
122
|
+
return this._buildError(err.status, err.code, err.message, err.details, correlation_id);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (err instanceof AbortError || (err && err.name === 'AbortError')) {
|
|
126
|
+
const message = err && err.message ? err.message : 'Invocation aborted';
|
|
127
|
+
this._logger.info(
|
|
128
|
+
{ err, operation, correlation_id, code: 'CLIENT_CANCELLED', status: 499 },
|
|
129
|
+
'invocation aborted by client'
|
|
130
|
+
);
|
|
131
|
+
return this._buildError(499, 'CLIENT_CANCELLED', message, undefined, correlation_id);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this._logger.error(
|
|
135
|
+
{ err, operation, correlation_id, code: 'INTERNAL_ERROR', status: 500, stack: err && err.stack },
|
|
136
|
+
'unhandled error during operation invocation'
|
|
137
|
+
);
|
|
138
|
+
return this._buildError(500, 'INTERNAL_ERROR', 'An internal error occurred', undefined, correlation_id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @private
|
|
143
|
+
*/
|
|
144
|
+
_buildError(status, code, message, details, correlation_id) {
|
|
145
|
+
const error = { code, message };
|
|
146
|
+
if (details !== undefined) {
|
|
147
|
+
error.details = details;
|
|
148
|
+
}
|
|
149
|
+
if (correlation_id) {
|
|
150
|
+
error.correlation_id = correlation_id;
|
|
151
|
+
}
|
|
152
|
+
return { status, error };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
ErrorMapper,
|
|
158
|
+
ValidationError,
|
|
159
|
+
UnknownOperationError,
|
|
160
|
+
BusinessError,
|
|
161
|
+
AbortError
|
|
162
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HandlerLoader
|
|
5
|
+
*
|
|
6
|
+
* L3 Orchestration helper used by HandlerRegistry. Resolves v3 handler
|
|
7
|
+
* references of the form `"relative/path#exportName"` to actual exported
|
|
8
|
+
* functions, relative to the host service's source directory.
|
|
9
|
+
*
|
|
10
|
+
* Layer: L3 (Orchestration). Pure filesystem + require. No cross-imports
|
|
11
|
+
* from other wrapper modules.
|
|
12
|
+
*
|
|
13
|
+
* @see api/docs/architecture/biz-service-invocation-model.md §5.5, §5.9.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
|
|
19
|
+
class HandlerLoader {
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} deps
|
|
22
|
+
* @param {string} deps.baseDir - Absolute path to the service source root
|
|
23
|
+
* (typically `path.join(serviceRoot, 'src')`, e.g. `/app/src`).
|
|
24
|
+
* @param {Object} deps.logger - Logger with at least debug/info/warn.
|
|
25
|
+
*/
|
|
26
|
+
constructor({ baseDir, logger } = {}) {
|
|
27
|
+
if (!baseDir) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'[HandlerLoader] baseDir is required - Expected absolute path to the service source root (e.g. /app/src)'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
if (!logger) {
|
|
33
|
+
throw new Error('[HandlerLoader] logger is required');
|
|
34
|
+
}
|
|
35
|
+
if (!path.isAbsolute(baseDir)) {
|
|
36
|
+
throw new Error(`[HandlerLoader] baseDir must be absolute - Got: ${baseDir}`);
|
|
37
|
+
}
|
|
38
|
+
if (!fs.existsSync(baseDir)) {
|
|
39
|
+
throw new Error(`[HandlerLoader] baseDir does not exist - ${baseDir}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
this._baseDir = baseDir;
|
|
43
|
+
this._logger = logger;
|
|
44
|
+
this._cache = new Map();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve `"relative/path#exportName"` to the exported function.
|
|
49
|
+
* Cached per handlerRef string. Throws on any malformed / missing input.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} handlerRef - v3 handler descriptor from operations.json.
|
|
52
|
+
* @returns {Function}
|
|
53
|
+
*/
|
|
54
|
+
resolve(handlerRef) {
|
|
55
|
+
if (typeof handlerRef !== 'string' || !handlerRef.includes('#')) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`[HandlerLoader] handlerRef must be 'relative/path#exportName' - Got: ${handlerRef}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
if (this._cache.has(handlerRef)) {
|
|
61
|
+
return this._cache.get(handlerRef);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const [relPath, exportName] = handlerRef.split('#');
|
|
65
|
+
if (!relPath || !exportName) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`[HandlerLoader] handlerRef malformed - Expected 'relative/path#exportName' got '${handlerRef}'`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const absModule = path.resolve(this._baseDir, relPath);
|
|
72
|
+
let mod;
|
|
73
|
+
try {
|
|
74
|
+
mod = require(absModule);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`[HandlerLoader] Failed to require '${absModule}' resolved from '${handlerRef}' - ${err.message}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const fn = mod[exportName];
|
|
82
|
+
if (typeof fn !== 'function') {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`[HandlerLoader] Export '${exportName}' not found or not a function in '${absModule}'`
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
this._cache.set(handlerRef, fn);
|
|
89
|
+
this._logger.debug(`[HandlerLoader] resolved '${handlerRef}' -> ${absModule}#${exportName}`);
|
|
90
|
+
return fn;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { HandlerLoader };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HandlerRegistry
|
|
5
|
+
*
|
|
6
|
+
* L3 Orchestration component for the v3 biz-service invocation model.
|
|
7
|
+
*
|
|
8
|
+
* Responsibility (single): map operation names (from operations.json) to
|
|
9
|
+
* in-process handler functions and invoke them by name. Nothing else —
|
|
10
|
+
* no schema validation, no context building, no error mapping, no MQ.
|
|
11
|
+
*
|
|
12
|
+
* Layer: L3 (Orchestration).
|
|
13
|
+
* Imports: ./ErrorMapper (L2) for UnknownOperationError.
|
|
14
|
+
* Consumed by: ServiceWrapper (L4). Never imports from ServiceWrapper,
|
|
15
|
+
* ContextBuilder, or SchemaValidator.
|
|
16
|
+
*
|
|
17
|
+
* Contract: see biz-service-invocation-model.md §5.5.
|
|
18
|
+
*
|
|
19
|
+
* @see api/docs/architecture/biz-service-invocation-model.md §5.5, §5.9
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { UnknownOperationError } = require('./ErrorMapper');
|
|
23
|
+
|
|
24
|
+
class HandlerRegistry {
|
|
25
|
+
/**
|
|
26
|
+
* @param {Object} deps
|
|
27
|
+
* @param {Object} deps.operations Parsed operations.json (v3): map opName -> opSpec.
|
|
28
|
+
* @param {Object} deps.handlerLoader Abstraction with .resolve(handlerRef) -> Function.
|
|
29
|
+
* `handlerRef` is the v3 "<relative/path>#<exportName>" form.
|
|
30
|
+
* Implemented by ServiceWrapper (it owns the service base dir).
|
|
31
|
+
* @param {Object} deps.logger Logger with at least .debug/.info; .trace is optional.
|
|
32
|
+
*/
|
|
33
|
+
constructor({ operations, handlerLoader, logger } = {}) {
|
|
34
|
+
if (!operations) {
|
|
35
|
+
throw new Error('[HandlerRegistry] operations is required - Expected parsed operations.json map (opName -> opSpec)');
|
|
36
|
+
}
|
|
37
|
+
if (!handlerLoader) {
|
|
38
|
+
throw new Error('[HandlerRegistry] handlerLoader is required - Expected object exposing resolve(handlerRef) -> Function');
|
|
39
|
+
}
|
|
40
|
+
if (!logger) {
|
|
41
|
+
throw new Error('[HandlerRegistry] logger is required - Expected logger with debug/info methods');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
this._operations = operations;
|
|
45
|
+
this._handlerLoader = handlerLoader;
|
|
46
|
+
this._logger = logger;
|
|
47
|
+
this._handlers = new Map();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Eagerly resolve every declared handler. Called once by
|
|
52
|
+
* ServiceWrapper.initialize() BEFORE MQ subscription (RFC §5.9).
|
|
53
|
+
*
|
|
54
|
+
* Fail-fast: throws on the first bad entry. No catch-and-continue.
|
|
55
|
+
*
|
|
56
|
+
* @returns {void}
|
|
57
|
+
* @throws {Error} On missing/invalid handler field or non-function resolution.
|
|
58
|
+
*/
|
|
59
|
+
validate() {
|
|
60
|
+
for (const [opName, opSpec] of Object.entries(this._operations)) {
|
|
61
|
+
if (!opSpec || typeof opSpec.handler !== 'string' || opSpec.handler.length === 0) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`[HandlerRegistry] Operation '${opName}' has no 'handler' field - ` +
|
|
64
|
+
`Expected operations.json to declare handler as string "<relative/path>#<exportName>"`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const handlerRef = opSpec.handler;
|
|
69
|
+
const fn = this._handlerLoader.resolve(handlerRef);
|
|
70
|
+
|
|
71
|
+
if (typeof fn !== 'function') {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`[HandlerRegistry] Operation '${opName}' handler '${handlerRef}' did not resolve to a function - ` +
|
|
74
|
+
`Expected the module to export an async function at the named key`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
this._handlers.set(opName, fn);
|
|
79
|
+
this._logger.debug(`[HandlerRegistry] registered handler for operation '${opName}' from '${handlerRef}'`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this._logger.info(`[HandlerRegistry] validated ${this._handlers.size} handler(s)`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Hot-path invocation. O(1) Map lookup, no filesystem I/O, no reflection.
|
|
87
|
+
*
|
|
88
|
+
* Handler errors propagate untouched — ErrorMapper is the component
|
|
89
|
+
* that translates them into response envelopes (RFC §5.8).
|
|
90
|
+
*
|
|
91
|
+
* @param {string} operation Operation name as declared in operations.json.
|
|
92
|
+
* @param {Object} input Already-validated input payload (SchemaValidator ran earlier).
|
|
93
|
+
* @param {Object} ctx Immutable OperationContext from ContextBuilder.
|
|
94
|
+
* @returns {Promise<*>} Whatever the handler returns.
|
|
95
|
+
* @throws {UnknownOperationError} If the operation is not registered.
|
|
96
|
+
*/
|
|
97
|
+
async invoke(operation, input, ctx) {
|
|
98
|
+
const fn = this._handlers.get(operation);
|
|
99
|
+
if (!fn) {
|
|
100
|
+
throw new UnknownOperationError(
|
|
101
|
+
`[HandlerRegistry] Operation '${operation}' is not registered - ` +
|
|
102
|
+
`Check operations.json and wrapper.initialize() order`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof this._logger.trace === 'function') {
|
|
107
|
+
this._logger.trace(`[HandlerRegistry] invoke '${operation}'`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return await fn(input, ctx);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = { HandlerRegistry };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* OperationContext — immutable per-invocation context passed to every
|
|
5
|
+
* business handler by ServiceWrapper.invokeOperation().
|
|
6
|
+
*
|
|
7
|
+
* Contract: RFC `api/docs/architecture/biz-service-invocation-model.md` §5.6.
|
|
8
|
+
*
|
|
9
|
+
* Exactly 14 named fields, assembled by ContextBuilder.build() once per
|
|
10
|
+
* invocation and frozen before the handler runs:
|
|
11
|
+
*
|
|
12
|
+
* Row-level scoping (from MQ envelope):
|
|
13
|
+
* tenant_id — integer, FK per tenant-context-contract.md
|
|
14
|
+
* workspace_id — integer
|
|
15
|
+
* person_id — integer | null (system invocations pass null)
|
|
16
|
+
*
|
|
17
|
+
* Correlation / identity:
|
|
18
|
+
* operation_name — string (mqMessage.operation)
|
|
19
|
+
* workflow_id — string (uuid)
|
|
20
|
+
* correlation_id — string (uuid)
|
|
21
|
+
*
|
|
22
|
+
* Infrastructure handles (acquired by the wrapper, not the handler):
|
|
23
|
+
* logger — Logger scoped to {service, operation, workflow_id, correlation_id}
|
|
24
|
+
* db — PgClient / pool handle (released in step 8 of §5.9)
|
|
25
|
+
* cache — CacheAdapter (tenant-namespaced)
|
|
26
|
+
* httpClient — axios instance
|
|
27
|
+
* secrets — { get(name) => Promise<string> }
|
|
28
|
+
* stream — (chunk) => Promise<void>
|
|
29
|
+
* abortSignal — AbortSignal
|
|
30
|
+
* config — read-only service config snapshot
|
|
31
|
+
*
|
|
32
|
+
* Design notes:
|
|
33
|
+
* - No getter helpers, no `.scoped()`, no methods. The handler destructures
|
|
34
|
+
* what it needs: `const { tenant_id, db, logger } = ctx;`.
|
|
35
|
+
* - `Object.freeze` prevents reassignment of top-level fields
|
|
36
|
+
* (`ctx.tenant_id = 99` throws in strict mode) but is shallow by design —
|
|
37
|
+
* `ctx.db.query(...)` and `ctx.logger.info(...)` must remain callable.
|
|
38
|
+
* - Lifecycle (release of db client, logger flush) is owned by ContextBuilder;
|
|
39
|
+
* see its `build()` return contract.
|
|
40
|
+
*/
|
|
41
|
+
class OperationContext {
|
|
42
|
+
/**
|
|
43
|
+
* @param {Object} fields - The 14 fields listed above, fully populated.
|
|
44
|
+
*/
|
|
45
|
+
constructor(fields) {
|
|
46
|
+
if (!fields || typeof fields !== 'object') {
|
|
47
|
+
throw new Error(
|
|
48
|
+
'[OperationContext] fields is required - Expected object with 14 named fields per RFC §5.6 ' +
|
|
49
|
+
'(tenant_id, workspace_id, person_id, operation_name, workflow_id, correlation_id, ' +
|
|
50
|
+
'logger, db, cache, httpClient, secrets, stream, abortSignal, config)'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
Object.assign(this, fields);
|
|
55
|
+
Object.freeze(this);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { OperationContext };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SchemaValidator - Thin wrapper over ajv for operations.json input/output validation.
|
|
5
|
+
*
|
|
6
|
+
* Contract: api/docs/architecture/biz-service-invocation-model.md §5.7
|
|
7
|
+
*
|
|
8
|
+
* - Precompiles every operation's input and output JSON Schema during
|
|
9
|
+
* ServiceWrapper.initialize() (see §5.9). Compile failure throws before
|
|
10
|
+
* MQ subscription.
|
|
11
|
+
* - validateInput throws ValidationError(phase='input') on mismatch (user bug → 400).
|
|
12
|
+
* - validateOutput throws ValidationError(phase='output') on mismatch (server bug → 500).
|
|
13
|
+
* - No coercion: a number-shaped string is an error. Upstream must send correct types.
|
|
14
|
+
*
|
|
15
|
+
* Layer: L2 (Infrastructure). Depends on ajv + ./ErrorMapper (sibling L2).
|
|
16
|
+
* MUST NOT import HandlerRegistry, ContextBuilder, or any L3/L4 component.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const { ValidationError } = require('./ErrorMapper');
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the default Ajv instance used when the caller does not inject one.
|
|
23
|
+
* Options are locked per RFC §5.7 — do not relax without an RFC amendment.
|
|
24
|
+
*
|
|
25
|
+
* @returns {object} Configured Ajv instance with ajv-formats applied.
|
|
26
|
+
*/
|
|
27
|
+
function _buildDefaultAjv() {
|
|
28
|
+
const Ajv = require('ajv');
|
|
29
|
+
const addFormats = require('ajv-formats');
|
|
30
|
+
const ajv = new Ajv({ strict: true, allErrors: true, removeAdditional: false });
|
|
31
|
+
addFormats(ajv);
|
|
32
|
+
return ajv;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Summarise the first ajv error into a human-readable string.
|
|
37
|
+
* Shape: `<phase>/<instancePath> <ajv message>`
|
|
38
|
+
*
|
|
39
|
+
* @param {string} phase - 'input' or 'output'.
|
|
40
|
+
* @param {Array<object>} errors - ajv error array (fn.errors).
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
function _summariseAjvError(phase, errors) {
|
|
44
|
+
if (!Array.isArray(errors) || errors.length === 0) {
|
|
45
|
+
return `${phase} failed schema validation`;
|
|
46
|
+
}
|
|
47
|
+
const first = errors[0];
|
|
48
|
+
const path = first.instancePath || '';
|
|
49
|
+
const msg = first.message || 'is invalid';
|
|
50
|
+
return `${phase}${path} ${msg}`.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class SchemaValidator {
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {object} [options.ajv] - Pre-configured Ajv instance (DI). If absent,
|
|
57
|
+
* a strict Ajv is created internally. Injection is for tests and for callers
|
|
58
|
+
* that need custom formats/keywords.
|
|
59
|
+
*/
|
|
60
|
+
constructor({ ajv } = {}) {
|
|
61
|
+
this._ajv = ajv || _buildDefaultAjv();
|
|
62
|
+
this._inputValidators = new Map();
|
|
63
|
+
this._outputValidators = new Map();
|
|
64
|
+
this._knownOperations = new Set();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compile input and output schemas for every declared operation.
|
|
69
|
+
* Fail-fast: any compile error throws immediately and aborts startup.
|
|
70
|
+
*
|
|
71
|
+
* @param {object} operations - operations.json v3 object (opName → spec).
|
|
72
|
+
* Each spec MAY have `.input` (JSON Schema) and `.output` (JSON Schema).
|
|
73
|
+
* Missing input or output is permitted (operation declares none).
|
|
74
|
+
* @returns {void}
|
|
75
|
+
*/
|
|
76
|
+
precompile(operations) {
|
|
77
|
+
if (!operations || typeof operations !== 'object') {
|
|
78
|
+
throw new Error(
|
|
79
|
+
'[SchemaValidator] operations is required - Expected object from operations.json v3'
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const [opName, spec] of Object.entries(operations)) {
|
|
84
|
+
this._knownOperations.add(opName);
|
|
85
|
+
|
|
86
|
+
if (spec && spec.input) {
|
|
87
|
+
let fn;
|
|
88
|
+
try {
|
|
89
|
+
fn = this._ajv.compile(spec.input);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`[SchemaValidator] Failed to compile input schema for operation ${opName} - ${err.message}`
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
this._inputValidators.set(opName, fn);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (spec && spec.output) {
|
|
99
|
+
let fn;
|
|
100
|
+
try {
|
|
101
|
+
fn = this._ajv.compile(spec.output);
|
|
102
|
+
} catch (err) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`[SchemaValidator] Failed to compile output schema for operation ${opName} - ${err.message}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
this._outputValidators.set(opName, fn);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validate input payload for a known operation.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} operation - Operation name from operations.json.
|
|
116
|
+
* @param {*} data - Payload to validate.
|
|
117
|
+
* @returns {null} Returns null on success (or when the op has no input schema).
|
|
118
|
+
* @throws {Error} When the operation was never precompiled (operator bug).
|
|
119
|
+
* @throws {ValidationError} When `data` does not conform to the compiled schema.
|
|
120
|
+
*/
|
|
121
|
+
validateInput(operation, data) {
|
|
122
|
+
if (!this._knownOperations.has(operation)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`[SchemaValidator] Unknown operation '${operation}' - Expected operations.json to declare it and precompile() to have run`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const fn = this._inputValidators.get(operation);
|
|
129
|
+
if (!fn) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const ok = fn(data);
|
|
134
|
+
if (ok) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const errors = fn.errors || [];
|
|
139
|
+
throw new ValidationError({
|
|
140
|
+
message: _summariseAjvError('input', errors),
|
|
141
|
+
phase: 'input',
|
|
142
|
+
operation,
|
|
143
|
+
details: errors
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Validate output payload produced by the handler.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} operation - Operation name from operations.json.
|
|
151
|
+
* @param {*} data - Handler result.
|
|
152
|
+
* @returns {null} Returns null on success (or when the op has no output schema).
|
|
153
|
+
* @throws {Error} When the operation was never precompiled (operator bug).
|
|
154
|
+
* @throws {ValidationError} Tagged `phase: 'output'` — ErrorMapper treats this as a server bug.
|
|
155
|
+
*/
|
|
156
|
+
validateOutput(operation, data) {
|
|
157
|
+
if (!this._knownOperations.has(operation)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`[SchemaValidator] Unknown operation '${operation}' - Expected operations.json to declare it and precompile() to have run`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const fn = this._outputValidators.get(operation);
|
|
164
|
+
if (!fn) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ok = fn(data);
|
|
169
|
+
if (ok) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const errors = fn.errors || [];
|
|
174
|
+
throw new ValidationError({
|
|
175
|
+
message: _summariseAjvError('output', errors),
|
|
176
|
+
phase: 'output',
|
|
177
|
+
operation,
|
|
178
|
+
details: errors
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { SchemaValidator };
|
package/src/ServiceWrapper.js
CHANGED
|
@@ -17,7 +17,6 @@ const MQConnector = require('@onlineapps/conn-infra-mq');
|
|
|
17
17
|
const RegistryConnector = require('@onlineapps/conn-orch-registry');
|
|
18
18
|
const MonitoringConnector = require('@onlineapps/conn-base-monitoring');
|
|
19
19
|
const OrchestratorConnector = require('@onlineapps/conn-orch-orchestrator');
|
|
20
|
-
const ApiMapperConnector = require('@onlineapps/conn-orch-api-mapper');
|
|
21
20
|
const CookbookConnector = require('@onlineapps/conn-orch-cookbook');
|
|
22
21
|
const CacheConnector = require('@onlineapps/conn-base-cache');
|
|
23
22
|
const StateConnector = require('@onlineapps/conn-base-state');
|
|
@@ -25,6 +24,13 @@ const ErrorHandlerConnector = require('@onlineapps/conn-infra-error-handler');
|
|
|
25
24
|
const { ValidationOrchestrator } = require('@onlineapps/conn-orch-validator');
|
|
26
25
|
const runtimeCfg = require('./config');
|
|
27
26
|
|
|
27
|
+
// v3 biz-service invocation model (RFC: api/docs/architecture/biz-service-invocation-model.md §5.9)
|
|
28
|
+
const { HandlerRegistry } = require('./HandlerRegistry');
|
|
29
|
+
const { HandlerLoader } = require('./HandlerLoader');
|
|
30
|
+
const { ContextBuilder } = require('./ContextBuilder');
|
|
31
|
+
const { SchemaValidator } = require('./SchemaValidator');
|
|
32
|
+
const { ErrorMapper, UnknownOperationError, ValidationError } = require('./ErrorMapper');
|
|
33
|
+
|
|
28
34
|
const RUNTIME_DIR = 'conn-runtime';
|
|
29
35
|
const PROOF_RELATIVE_PATH = `${RUNTIME_DIR}/validation-proof.json`;
|
|
30
36
|
|
|
@@ -78,6 +84,23 @@ class ServiceWrapper {
|
|
|
78
84
|
this.validationProof = options.validationProof || null;
|
|
79
85
|
this._injectedValidationOrchestrator = options._validationOrchestrator || null;
|
|
80
86
|
|
|
87
|
+
// v3 invocation model: absolute path to the host service's source dir
|
|
88
|
+
// (e.g. /app/src). Used by HandlerLoader to require handler modules from
|
|
89
|
+
// operations.json (handler: "relative/path#exportName"). See RFC §5.9.
|
|
90
|
+
if (!options.serviceBaseDir) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
'[ServiceWrapper] serviceBaseDir is required - Expected absolute path like /app/src (pass __dirname from the service index.js, or path.join(serviceRoot, "src"))'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
this._serviceBaseDir = options.serviceBaseDir;
|
|
96
|
+
|
|
97
|
+
// v3 pipeline components (instantiated in initialize(), after connectors).
|
|
98
|
+
this._handlerLoader = null;
|
|
99
|
+
this._handlerRegistry = null;
|
|
100
|
+
this._contextBuilder = null;
|
|
101
|
+
this._schemaValidator = null;
|
|
102
|
+
this._errorMapper = null;
|
|
103
|
+
|
|
81
104
|
// Fail-fast: enforce naming conventions and workspaceScoped invariants.
|
|
82
105
|
// See api/docs/standards/OPERATIONS.md and biz-service-onboarding.md §3.1 / §4.
|
|
83
106
|
// Rejection here keeps the contract authoritative at the earliest possible
|
|
@@ -575,14 +598,11 @@ class ServiceWrapper {
|
|
|
575
598
|
// Konfigurace se načítá v konstruktoru, takže tady jen logujeme
|
|
576
599
|
this._logPhase('0.1', 'Configuration Load', 'PASSED', null, Date.now() - startTime);
|
|
577
600
|
|
|
578
|
-
// FÁZE 0.1b: Operations-Routes alignment
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
} catch (error) {
|
|
584
|
-
this._handleInitializationError('0.1b', 'Operations-Routes Alignment', error, false);
|
|
585
|
-
}
|
|
601
|
+
// FÁZE 0.1b: v2 Operations-Routes alignment validator retired.
|
|
602
|
+
// v3 replaces it with _validateOperationsHandlerAlignment(), invoked
|
|
603
|
+
// from the v3 pipeline block below (after connectors are ready).
|
|
604
|
+
// See RFC api/docs/architecture/biz-service-invocation-model.md §5.9 step 5.
|
|
605
|
+
this._logPhase('0.1b', 'Operations-Routes Alignment', 'SKIPPED', null, 0);
|
|
586
606
|
|
|
587
607
|
// FÁZE 0.2: Tier 1 validace (PŘED MQ připojením)
|
|
588
608
|
if (this.serviceRoot && this.config.wrapper?.validation?.enabled !== false) {
|
|
@@ -671,6 +691,11 @@ class ServiceWrapper {
|
|
|
671
691
|
await this._initializeState();
|
|
672
692
|
}
|
|
673
693
|
|
|
694
|
+
// v3 handler-registry pipeline (RFC §5.9). Replaces the v2 HTTP-loopback
|
|
695
|
+
// invocation path. Must run AFTER connectors are instantiated (storage,
|
|
696
|
+
// cache, monitoring, mq) and BEFORE MQ messages are dispatched.
|
|
697
|
+
this._initializeInvocationPipeline();
|
|
698
|
+
|
|
674
699
|
// Setup health checks
|
|
675
700
|
if (this.config.wrapper?.health?.enabled !== false) {
|
|
676
701
|
this._setupHealthChecks();
|
|
@@ -1485,20 +1510,17 @@ class ServiceWrapper {
|
|
|
1485
1510
|
throw new Error('[ServiceWrapper] Logger not initialized — cannot create orchestrator');
|
|
1486
1511
|
}
|
|
1487
1512
|
|
|
1488
|
-
const apiMapper = ApiMapperConnector.create({
|
|
1489
|
-
// operations.json contract (see operations-registry-contract.md §3)
|
|
1490
|
-
operations: this.operations,
|
|
1491
|
-
serviceUrl: serviceUrl,
|
|
1492
|
-
directCall: false,
|
|
1493
|
-
logger: this.logger
|
|
1494
|
-
});
|
|
1495
|
-
|
|
1496
1513
|
const timeout = this.config.wrapper?.timeout || 30000;
|
|
1497
1514
|
|
|
1515
|
+
// P1.5b: orchestrator now dispatches via service-wrapper.invokeOperation
|
|
1516
|
+
// (intra-service handler dispatch, RFC §5.9, §5.10) — no more HTTP-loopback
|
|
1517
|
+
// through the retired conn-orch-api-mapper.
|
|
1498
1518
|
this.orchestrator = OrchestratorConnector.create({
|
|
1499
1519
|
mqClient: this.mqClient,
|
|
1500
1520
|
registryClient: this.registryClient,
|
|
1501
|
-
|
|
1521
|
+
invoker: {
|
|
1522
|
+
invokeOperation: this.invokeOperation.bind(this)
|
|
1523
|
+
},
|
|
1502
1524
|
cookbook: CookbookConnector,
|
|
1503
1525
|
cache: this.cacheConnector,
|
|
1504
1526
|
errorHandler: errorHandler,
|
|
@@ -1651,11 +1673,14 @@ class ServiceWrapper {
|
|
|
1651
1673
|
// Process based on message type
|
|
1652
1674
|
let result;
|
|
1653
1675
|
if (message.operation && this.operations?.operations?.[message.operation]) {
|
|
1654
|
-
//
|
|
1655
|
-
|
|
1676
|
+
// v3 handler-registry dispatch. Replaces v2 apiMapper.callAPI /
|
|
1677
|
+
// _executeOperation HTTP-loopback path.
|
|
1678
|
+
// See api/docs/architecture/biz-service-invocation-model.md §5.9.
|
|
1679
|
+
result = await this._dispatchViaInvokeOperation(message, message.operation);
|
|
1656
1680
|
} else if (message.step?.operation && this.operations?.operations?.[message.step.operation]) {
|
|
1657
|
-
//
|
|
1658
|
-
|
|
1681
|
+
// v3 handler-registry dispatch for workflow-step invocation.
|
|
1682
|
+
// See api/docs/architecture/biz-service-invocation-model.md §5.9.
|
|
1683
|
+
result = await this._dispatchViaInvokeOperation(message, message.step.operation);
|
|
1659
1684
|
} else if (this.orchestrator) {
|
|
1660
1685
|
// Delegate to orchestrator for complex workflow processing
|
|
1661
1686
|
// Validate required fields before processing
|
|
@@ -1735,7 +1760,57 @@ class ServiceWrapper {
|
|
|
1735
1760
|
}
|
|
1736
1761
|
|
|
1737
1762
|
/**
|
|
1738
|
-
*
|
|
1763
|
+
* Adapt an MQ workflow message into the v3 invokeOperation contract,
|
|
1764
|
+
* invoke it, and unwrap the returned envelope to match the previous
|
|
1765
|
+
* `_executeOperation` return contract (result object on success, throws
|
|
1766
|
+
* on error) so the surrounding _processWorkflowMessage flow is unchanged.
|
|
1767
|
+
*
|
|
1768
|
+
* See RFC §5.9 for envelope shape.
|
|
1769
|
+
* @private
|
|
1770
|
+
*/
|
|
1771
|
+
async _dispatchViaInvokeOperation(message, operationName) {
|
|
1772
|
+
if (!this._handlerRegistry) {
|
|
1773
|
+
throw new Error(
|
|
1774
|
+
'[ServiceWrapper] Invocation pipeline not initialized - _dispatchViaInvokeOperation called before initialize() completed'
|
|
1775
|
+
);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
// Build the mqMessage in the shape ContextBuilder/invokeOperation expect.
|
|
1779
|
+
// Envelope carries tenancy (from upstream gateway). Workflow/correlation
|
|
1780
|
+
// IDs are passed through; missing ones are defaulted to the step id so
|
|
1781
|
+
// ContextBuilder's non-empty-string validation passes for legacy inputs.
|
|
1782
|
+
const envelope = message.envelope || message.context?.envelope || message.context || {};
|
|
1783
|
+
const workflow_id = message.workflow_id || message.workflowId || operationName;
|
|
1784
|
+
const correlation_id = message.correlation_id || message.correlationId || workflow_id;
|
|
1785
|
+
|
|
1786
|
+
const mqMessage = {
|
|
1787
|
+
operation: operationName,
|
|
1788
|
+
input: message.input || {},
|
|
1789
|
+
envelope,
|
|
1790
|
+
workflow_id,
|
|
1791
|
+
correlation_id,
|
|
1792
|
+
abortSignal: message.abortSignal
|
|
1793
|
+
};
|
|
1794
|
+
|
|
1795
|
+
const envelopeOut = await this.invokeOperation(mqMessage);
|
|
1796
|
+
if (envelopeOut && envelopeOut.status >= 200 && envelopeOut.status < 300) {
|
|
1797
|
+
return envelopeOut.result;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
const errInfo = envelopeOut && envelopeOut.error ? envelopeOut.error : { code: 'INTERNAL_ERROR', message: 'Unknown error' };
|
|
1801
|
+
const err = new Error(`[ServiceWrapper] Operation '${operationName}' failed: ${errInfo.message}`);
|
|
1802
|
+
err.code = errInfo.code;
|
|
1803
|
+
err.status = envelopeOut && envelopeOut.status;
|
|
1804
|
+
err.details = errInfo.details;
|
|
1805
|
+
throw err;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
/**
|
|
1809
|
+
* Execute operation by calling HTTP endpoint (v2 HTTP-loopback path).
|
|
1810
|
+
*
|
|
1811
|
+
* v2 legacy — no longer invoked from the MQ dispatch path; retained for
|
|
1812
|
+
* any downstream caller until P5.1 cleanup.
|
|
1813
|
+
*
|
|
1739
1814
|
* @private
|
|
1740
1815
|
*/
|
|
1741
1816
|
async _executeOperation(operationName, input) {
|
|
@@ -1849,10 +1924,162 @@ class ServiceWrapper {
|
|
|
1849
1924
|
return routes;
|
|
1850
1925
|
}
|
|
1851
1926
|
|
|
1927
|
+
/**
|
|
1928
|
+
* Instantiate the v3 invocation pipeline (HandlerRegistry, SchemaValidator,
|
|
1929
|
+
* ContextBuilder, ErrorMapper, HandlerLoader). Called from initialize()
|
|
1930
|
+
* once connectors are up.
|
|
1931
|
+
*
|
|
1932
|
+
* Fail-fast: HandlerRegistry.validate() and SchemaValidator.precompile()
|
|
1933
|
+
* throw at the first bad entry in operations.json.
|
|
1934
|
+
*
|
|
1935
|
+
* @see api/docs/architecture/biz-service-invocation-model.md §5.9
|
|
1936
|
+
* @private
|
|
1937
|
+
*/
|
|
1938
|
+
_initializeInvocationPipeline() {
|
|
1939
|
+
const opsMap = (this.operations && this.operations.operations) || this.operations;
|
|
1940
|
+
if (!opsMap || typeof opsMap !== 'object') {
|
|
1941
|
+
throw new Error(
|
|
1942
|
+
'[ServiceWrapper] Cannot initialize invocation pipeline - operations map missing; expected options.operations.operations or options.operations to be an object'
|
|
1943
|
+
);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
const logger = this.logger;
|
|
1947
|
+
if (!logger) {
|
|
1948
|
+
throw new Error(
|
|
1949
|
+
'[ServiceWrapper] Cannot initialize invocation pipeline - logger not ready; monitoring must be initialized first'
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
this._handlerLoader = new HandlerLoader({
|
|
1954
|
+
baseDir: this._serviceBaseDir,
|
|
1955
|
+
logger
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
this._handlerRegistry = new HandlerRegistry({
|
|
1959
|
+
operations: opsMap,
|
|
1960
|
+
handlerLoader: this._handlerLoader,
|
|
1961
|
+
logger
|
|
1962
|
+
});
|
|
1963
|
+
this._handlerRegistry.validate();
|
|
1964
|
+
|
|
1965
|
+
this._schemaValidator = new SchemaValidator();
|
|
1966
|
+
this._schemaValidator.precompile(opsMap);
|
|
1967
|
+
|
|
1968
|
+
this._contextBuilder = new ContextBuilder({
|
|
1969
|
+
connectors: {
|
|
1970
|
+
storage: this.stateConnector || null,
|
|
1971
|
+
cache: this.cacheConnector || null,
|
|
1972
|
+
http: null,
|
|
1973
|
+
secrets: null,
|
|
1974
|
+
monitoring: this.monitoring || null,
|
|
1975
|
+
mq: this.mqClient || null
|
|
1976
|
+
},
|
|
1977
|
+
config: this.config,
|
|
1978
|
+
logger
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
this._errorMapper = new ErrorMapper({ logger });
|
|
1982
|
+
|
|
1983
|
+
this._validateOperationsHandlerAlignment();
|
|
1984
|
+
|
|
1985
|
+
logger.info('[ServiceWrapper] v3 invocation pipeline initialized');
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* v3 alignment validator — replaces _validateOperationsRouteAlignment.
|
|
1990
|
+
*
|
|
1991
|
+
* Cross-checks that every operation declared in operations.json has a
|
|
1992
|
+
* registered handler in HandlerRegistry, and that no handler is registered
|
|
1993
|
+
* for an operation that is not declared (orphan detection).
|
|
1994
|
+
*
|
|
1995
|
+
* HandlerRegistry.validate() already resolved every handler ref; this method
|
|
1996
|
+
* is a defence-in-depth check that the two sets agree.
|
|
1997
|
+
*
|
|
1998
|
+
* @see api/docs/architecture/biz-service-invocation-model.md §5.9 step 5
|
|
1999
|
+
* @private
|
|
2000
|
+
* @throws {Error} When declarations and registrations diverge.
|
|
2001
|
+
*/
|
|
2002
|
+
_validateOperationsHandlerAlignment() {
|
|
2003
|
+
const opsMap = (this.operations && this.operations.operations) || this.operations || {};
|
|
2004
|
+
const registeredOps = new Set(this._handlerRegistry._handlers.keys());
|
|
2005
|
+
const declaredOps = new Set(Object.keys(opsMap));
|
|
2006
|
+
const missing = [...declaredOps].filter((op) => !registeredOps.has(op));
|
|
2007
|
+
const orphaned = [...registeredOps].filter((op) => !declaredOps.has(op));
|
|
2008
|
+
if (missing.length) {
|
|
2009
|
+
throw new Error(
|
|
2010
|
+
`[ServiceWrapper] Operations declared without registered handler: ${missing.join(', ')}`
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
if (orphaned.length) {
|
|
2014
|
+
throw new Error(
|
|
2015
|
+
`[ServiceWrapper] Registered handlers without declaration: ${orphaned.join(', ')}`
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
this.logger?.info({ count: declaredOps.size }, 'operations-handler alignment verified');
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
/**
|
|
2022
|
+
* v3 single-operation invocation (RFC §5.9).
|
|
2023
|
+
*
|
|
2024
|
+
* Dispatch path for MQ-delivered operation messages. Never throws — always
|
|
2025
|
+
* returns an envelope `{ status, result }` or `{ status, error }` produced
|
|
2026
|
+
* by ErrorMapper.
|
|
2027
|
+
*
|
|
2028
|
+
* @param {Object} mqMessage - Parsed MQ message with { operation, input,
|
|
2029
|
+
* envelope, workflow_id, correlation_id, ... }.
|
|
2030
|
+
* @returns {Promise<{ status: number, result?: *, error?: Object }>}
|
|
2031
|
+
*/
|
|
2032
|
+
async invokeOperation(mqMessage) {
|
|
2033
|
+
const operation = mqMessage && mqMessage.operation;
|
|
2034
|
+
let ctx;
|
|
2035
|
+
let release;
|
|
2036
|
+
try {
|
|
2037
|
+
if (!operation) {
|
|
2038
|
+
throw new UnknownOperationError('[ServiceWrapper] mqMessage.operation is required');
|
|
2039
|
+
}
|
|
2040
|
+
const opsMap = (this.operations && this.operations.operations) || this.operations || {};
|
|
2041
|
+
const opSpec = opsMap[operation];
|
|
2042
|
+
if (!opSpec) {
|
|
2043
|
+
throw new UnknownOperationError(
|
|
2044
|
+
`[ServiceWrapper] Operation '${operation}' is not in operations.json`
|
|
2045
|
+
);
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const built = await this._contextBuilder.build(mqMessage, opSpec);
|
|
2049
|
+
ctx = built.ctx;
|
|
2050
|
+
release = built.release;
|
|
2051
|
+
|
|
2052
|
+
const input = mqMessage.input || {};
|
|
2053
|
+
this._schemaValidator.validateInput(operation, input);
|
|
2054
|
+
|
|
2055
|
+
const result = await this._handlerRegistry.invoke(operation, input, ctx);
|
|
2056
|
+
|
|
2057
|
+
this._schemaValidator.validateOutput(operation, result);
|
|
2058
|
+
|
|
2059
|
+
return { status: 200, result };
|
|
2060
|
+
} catch (err) {
|
|
2061
|
+
return this._errorMapper.map(err, {
|
|
2062
|
+
operation,
|
|
2063
|
+
correlation_id: mqMessage && mqMessage.correlation_id
|
|
2064
|
+
});
|
|
2065
|
+
} finally {
|
|
2066
|
+
if (release) {
|
|
2067
|
+
try {
|
|
2068
|
+
await release();
|
|
2069
|
+
} catch (e) {
|
|
2070
|
+
this.logger?.error({ err: e, operation }, 'context release failed');
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
|
|
1852
2076
|
/**
|
|
1853
2077
|
* Bidirectional validation: operations.json <-> Express routes.
|
|
1854
2078
|
* Check A: every declared operation has a matching Express route.
|
|
1855
2079
|
* Check B: every /api/* route has a matching operation or is in _exclude.
|
|
2080
|
+
*
|
|
2081
|
+
* v2 legacy — no longer invoked; scheduled for removal in P5.1.
|
|
2082
|
+
*
|
|
1856
2083
|
* @private
|
|
1857
2084
|
* @throws {Error} Permanent error listing all mismatches
|
|
1858
2085
|
*/
|
package/src/index.js
CHANGED
|
@@ -137,10 +137,15 @@ async function bootstrap(serviceRoot, options = {}) {
|
|
|
137
137
|
});
|
|
138
138
|
|
|
139
139
|
// 2. Initialize Service Wrapper (MQ, Registry, Monitoring, etc.)
|
|
140
|
+
// v3 invocation model: HandlerLoader requires the service's src/ as base
|
|
141
|
+
// dir for resolving handler refs (RFC §5.9). Biz services may override by
|
|
142
|
+
// passing options.serviceBaseDir via bootstrap().
|
|
143
|
+
const serviceBaseDir = options.serviceBaseDir || path.join(serviceRoot, 'src');
|
|
140
144
|
const wrapper = new ServiceWrapper({
|
|
141
145
|
app,
|
|
142
146
|
server,
|
|
143
147
|
serviceRoot,
|
|
148
|
+
serviceBaseDir,
|
|
144
149
|
config: {
|
|
145
150
|
service: {
|
|
146
151
|
name: config.service.name,
|
|
@@ -187,10 +192,36 @@ async function bootstrap(serviceRoot, options = {}) {
|
|
|
187
192
|
return { wrapper, server };
|
|
188
193
|
}
|
|
189
194
|
|
|
195
|
+
// v3 invocation-model components (RFC §5.9): exported for biz services
|
|
196
|
+
// and tests that need to instantiate or reference them directly.
|
|
197
|
+
const { HandlerRegistry } = require('./HandlerRegistry');
|
|
198
|
+
const { HandlerLoader } = require('./HandlerLoader');
|
|
199
|
+
const { OperationContext } = require('./OperationContext');
|
|
200
|
+
const { ContextBuilder } = require('./ContextBuilder');
|
|
201
|
+
const { SchemaValidator } = require('./SchemaValidator');
|
|
202
|
+
const {
|
|
203
|
+
ErrorMapper,
|
|
204
|
+
ValidationError,
|
|
205
|
+
UnknownOperationError,
|
|
206
|
+
BusinessError,
|
|
207
|
+
AbortError
|
|
208
|
+
} = require('./ErrorMapper');
|
|
209
|
+
|
|
190
210
|
module.exports = ServiceWrapper;
|
|
191
211
|
module.exports.ServiceWrapper = ServiceWrapper;
|
|
192
212
|
module.exports.ConfigLoader = ConfigLoader;
|
|
193
213
|
module.exports.bootstrap = bootstrap;
|
|
194
214
|
module.exports.createTenantContextMiddleware = createTenantContextMiddleware;
|
|
195
215
|
module.exports.default = ServiceWrapper;
|
|
196
|
-
module.exports.VERSION = pkg.version;
|
|
216
|
+
module.exports.VERSION = pkg.version;
|
|
217
|
+
|
|
218
|
+
module.exports.HandlerRegistry = HandlerRegistry;
|
|
219
|
+
module.exports.HandlerLoader = HandlerLoader;
|
|
220
|
+
module.exports.OperationContext = OperationContext;
|
|
221
|
+
module.exports.ContextBuilder = ContextBuilder;
|
|
222
|
+
module.exports.SchemaValidator = SchemaValidator;
|
|
223
|
+
module.exports.ErrorMapper = ErrorMapper;
|
|
224
|
+
module.exports.ValidationError = ValidationError;
|
|
225
|
+
module.exports.UnknownOperationError = UnknownOperationError;
|
|
226
|
+
module.exports.BusinessError = BusinessError;
|
|
227
|
+
module.exports.AbortError = AbortError;
|