@onlineapps/service-wrapper 3.4.6 → 3.4.7
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/examples/README.md +1 -1
- package/jest.config.js +15 -2
- package/jest.integration.config.js +29 -0
- package/package.json +8 -8
- package/src/ContextBuilder.js +34 -13
- package/src/ErrorMapper.js +140 -24
- package/src/OperationContext.js +30 -34
- package/src/SchemaValidator.js +2 -4
- package/src/index.js +24 -2
- package/src/logger.js +116 -0
package/examples/README.md
CHANGED
|
@@ -166,5 +166,5 @@ curl -X POST http://localhost:33000/workflow \
|
|
|
166
166
|
|
|
167
167
|
- [Operations Standard](/docs/OPERATIONS_STANDARD.md)
|
|
168
168
|
- [Service Wrapper Configuration](../docs/CONFIGURATION_GUIDE.md)
|
|
169
|
-
- [Workflow
|
|
169
|
+
- [Workflow Architecture](/docs/architecture/workflow.md)
|
|
170
170
|
- [Hello Service Examples](/services/hello-service/docs/WORKFLOW_EXECUTION.md)
|
package/jest.config.js
CHANGED
|
@@ -9,8 +9,16 @@ module.exports = {
|
|
|
9
9
|
testMatch: [
|
|
10
10
|
'**/tests/**/*.test.js'
|
|
11
11
|
],
|
|
12
|
+
// The integration tier owns its runner (jest.integration.config.js) because it
|
|
13
|
+
// needs a globalSetup that probes its live sidecar and env config that fails
|
|
14
|
+
// fast — and because it may not use the moduleNameMapper below, which points
|
|
15
|
+
// the connectors at mocks.
|
|
16
|
+
// Picked up by THIS config it ran without that setup, which is why it used to
|
|
17
|
+
// gate itself with `it.skip(...)` and report green while executing nothing.
|
|
18
|
+
// Run it with `npm run test:integration`.
|
|
12
19
|
testPathIgnorePatterns: [
|
|
13
|
-
'/node_modules/'
|
|
20
|
+
'/node_modules/',
|
|
21
|
+
'/tests/integration/'
|
|
14
22
|
],
|
|
15
23
|
coverageThresholds: {
|
|
16
24
|
global: {
|
|
@@ -28,7 +36,12 @@ module.exports = {
|
|
|
28
36
|
'@onlineapps/conn-orch-orchestrator': '<rootDir>/tests/mocks/connectors.js',
|
|
29
37
|
'@onlineapps/conn-orch-orchestrator/package.json': '<rootDir>/tests/mocks/orchestrator-package-v2.js',
|
|
30
38
|
'@onlineapps/conn-orch-cookbook': '<rootDir>/tests/mocks/connectors.js',
|
|
31
|
-
|
|
39
|
+
// Anchored on purpose: moduleNameMapper keys are unanchored regexes, so the
|
|
40
|
+
// bare form also matches every absolute path containing the package name —
|
|
41
|
+
// including node_modules/@onlineapps/conn-base-state/src/index.js itself.
|
|
42
|
+
// That made the real connector unreachable from any test in this package.
|
|
43
|
+
// tests/integration/state-in-context.integration.test.js loads it by path.
|
|
44
|
+
'^@onlineapps/conn-base-state$': '<rootDir>/tests/mocks/connectors.js'
|
|
32
45
|
},
|
|
33
46
|
setupFilesAfterEnv: ['<rootDir>/tests/setup.js'],
|
|
34
47
|
verbose: true
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Separate Jest runner for the integration tier.
|
|
5
|
+
*
|
|
6
|
+
* It exists for two reasons, both of which the shared `jest.config.js` cannot
|
|
7
|
+
* serve:
|
|
8
|
+
*
|
|
9
|
+
* 1. `globalSetup` probes the live Redis and aborts the run when it is absent,
|
|
10
|
+
* so the tier can never report a result on an environment that cannot serve
|
|
11
|
+
* it (`tests/integration/setup.js`).
|
|
12
|
+
* 2. NO `moduleNameMapper`. The shared config maps the connectors to
|
|
13
|
+
* `tests/mocks/connectors.js`, which is correct for the unit tier and wrong
|
|
14
|
+
* here: an integration test that runs against mocks proves the call was
|
|
15
|
+
* made, never the result (`architecture-principles.md` §10a — mocks are for
|
|
16
|
+
* neighbours, never for the SUT).
|
|
17
|
+
*
|
|
18
|
+
* `tests/setup.js` is deliberately NOT loaded: it silences `console.error` and
|
|
19
|
+
* `console.warn`, which is exactly the output an integration failure needs.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
testEnvironment: 'node',
|
|
24
|
+
testMatch: ['**/tests/integration/**/*.test.js'],
|
|
25
|
+
testTimeout: 30000,
|
|
26
|
+
globalSetup: './tests/integration/setup.js',
|
|
27
|
+
coverageDirectory: 'coverage-integration',
|
|
28
|
+
verbose: true
|
|
29
|
+
};
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onlineapps/service-wrapper",
|
|
3
|
-
"version": "3.4.
|
|
3
|
+
"version": "3.4.7",
|
|
4
4
|
"description": "Thin orchestration layer for microservices - delegates all infrastructure concerns to specialized connectors",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"test": "jest",
|
|
8
8
|
"test:unit": "jest tests/unit",
|
|
9
9
|
"test:component": "jest tests/component",
|
|
10
|
-
"test:integration": "jest
|
|
10
|
+
"test:integration": "jest --config=jest.integration.config.js",
|
|
11
11
|
"test:coverage": "jest --coverage",
|
|
12
12
|
"test:mocked": "node test/run-tests.js",
|
|
13
13
|
"docs": "jsdoc2md --files src/**/*.js > API.md",
|
|
@@ -25,19 +25,19 @@
|
|
|
25
25
|
"license": "MIT",
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"@onlineapps/conn-base-cache": "1.0.9",
|
|
28
|
-
"@onlineapps/conn-base-monitoring": "1.0.
|
|
28
|
+
"@onlineapps/conn-base-monitoring": "1.0.15",
|
|
29
29
|
"@onlineapps/conn-base-state": "1.0.1",
|
|
30
|
-
"@onlineapps/conn-infra-error-handler": "1.0.
|
|
30
|
+
"@onlineapps/conn-infra-error-handler": "1.0.14",
|
|
31
31
|
"@onlineapps/conn-infra-mq": "1.1.70",
|
|
32
32
|
"@onlineapps/conn-infra-secrets": "1.0.0",
|
|
33
33
|
"@onlineapps/conn-orch-cookbook": "2.1.4",
|
|
34
|
-
"@onlineapps/conn-orch-orchestrator": "2.1.
|
|
34
|
+
"@onlineapps/conn-orch-orchestrator": "2.1.7",
|
|
35
35
|
"@onlineapps/conn-orch-registry": "1.2.2",
|
|
36
36
|
"@onlineapps/conn-orch-validator": "3.3.2",
|
|
37
|
-
"@onlineapps/infrastructure-tools": "1.2.
|
|
38
|
-
"@onlineapps/monitoring-core": "1.0.
|
|
37
|
+
"@onlineapps/infrastructure-tools": "1.2.6",
|
|
38
|
+
"@onlineapps/monitoring-core": "1.0.26",
|
|
39
39
|
"@onlineapps/runtime-config": "1.0.2",
|
|
40
|
-
"@onlineapps/service-common": "
|
|
40
|
+
"@onlineapps/service-common": "2.0.0",
|
|
41
41
|
"ajv": "8.17.1",
|
|
42
42
|
"ajv-formats": "3.0.1"
|
|
43
43
|
},
|
package/src/ContextBuilder.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const { OperationContext } = require('./OperationContext');
|
|
4
|
+
const { InvalidEnvelopeError } = require('./ErrorMapper');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* ContextBuilder — assembles an OperationContext for a single invocation.
|
|
@@ -8,8 +9,11 @@ const { OperationContext } = require('./OperationContext');
|
|
|
8
9
|
* Contract: RFC `api/docs/architecture/biz-service-invocation-model.md` §5.6 + §5.9.
|
|
9
10
|
* Layer: L3 orchestration (see ARCHITECTURE_PRINCIPLES.md §7).
|
|
10
11
|
* - Depends on connectors (L1/L2) via constructor injection only.
|
|
11
|
-
* - Imports `./OperationContext`
|
|
12
|
-
*
|
|
12
|
+
* - Imports `./OperationContext` and the error CLASSES from `./ErrorMapper`
|
|
13
|
+
* (L2 types, no behaviour — ErrorMapper itself imports nothing, so there is
|
|
14
|
+
* no cycle; `./OperationContext` already depends on the same module).
|
|
15
|
+
* MUST NOT import HandlerRegistry or SchemaValidator, and MUST NOT call
|
|
16
|
+
* ErrorMapper.map() — mapping stays the wrapper's job.
|
|
13
17
|
* - Does NOT validate handler input/output schemas (SchemaValidator owns that).
|
|
14
18
|
* - Does NOT invoke handlers (ServiceWrapper owns that).
|
|
15
19
|
*
|
|
@@ -20,14 +24,14 @@ const { OperationContext } = require('./OperationContext');
|
|
|
20
24
|
class ContextBuilder {
|
|
21
25
|
/**
|
|
22
26
|
* @param {Object} deps
|
|
23
|
-
* @param {Object} deps.connectors - { storage, cache, http, secrets, monitoring, mq }
|
|
27
|
+
* @param {Object} deps.connectors - { storage, cache, http, secrets, state, monitoring, mq }
|
|
24
28
|
* @param {Object} deps.config - Read-only service config snapshot.
|
|
25
29
|
* @param {Object} deps.logger - Base logger (must expose `.child()` to be useful).
|
|
26
30
|
*/
|
|
27
31
|
constructor({ connectors, config, logger } = {}) {
|
|
28
32
|
if (!connectors) {
|
|
29
33
|
throw new Error(
|
|
30
|
-
'[ContextBuilder] connectors is required - Expected { storage, cache, http, secrets, monitoring, mq }'
|
|
34
|
+
'[ContextBuilder] connectors is required - Expected { storage, cache, http, secrets, state, monitoring, mq }'
|
|
31
35
|
);
|
|
32
36
|
}
|
|
33
37
|
if (!config) {
|
|
@@ -54,7 +58,7 @@ class ContextBuilder {
|
|
|
54
58
|
* Unset bundle_scope defaults to 'workspace' (strictest) per RFC §5.6 policy.
|
|
55
59
|
* 3. Scoped logger (via base.child() when available; fall back to base logger
|
|
56
60
|
* only when the base does not implement .child — bunyan/pino both do).
|
|
57
|
-
* 4. Connector acquisition (db, cache, httpClient, secrets facade).
|
|
61
|
+
* 4. Connector acquisition (db, cache, httpClient, state, secrets facade).
|
|
58
62
|
* 5. stream(chunk) -> mq.publishChunk (TODO P4.x biz-aiclient integration).
|
|
59
63
|
* 6. abortSignal — external mqMessage.abortSignal wins; otherwise a no-op
|
|
60
64
|
* controller (plumbing for cancellation is intentionally out of scope here).
|
|
@@ -67,6 +71,8 @@ class ContextBuilder {
|
|
|
67
71
|
*/
|
|
68
72
|
async build(mqMessage, operationSpec) {
|
|
69
73
|
this._validateMqMessage(mqMessage);
|
|
74
|
+
// Untyped on purpose: a missing operationSpec means operations.json or the
|
|
75
|
+
// registry lookup is broken — our bug, so it must surface as a 500.
|
|
70
76
|
if (!operationSpec || typeof operationSpec !== 'object') {
|
|
71
77
|
throw new Error(
|
|
72
78
|
'[ContextBuilder] operationSpec is required - Expected operations.json entry with bundle_scope'
|
|
@@ -90,6 +96,10 @@ class ContextBuilder {
|
|
|
90
96
|
const { db, releaseDb } = await this._acquireDb();
|
|
91
97
|
const cache = this._connectors.cache;
|
|
92
98
|
const httpClient = this._connectors.http;
|
|
99
|
+
// Persistent Redis state (conn-base-state). Plain pass-through like `cache`:
|
|
100
|
+
// the connector namespaces its keys by `state:<serviceName>:` on its own, so
|
|
101
|
+
// there is no invocation scope for the builder to inject.
|
|
102
|
+
const state = this._connectors.state;
|
|
93
103
|
const secretsAdapter = this._connectors.secrets;
|
|
94
104
|
const secrets = {
|
|
95
105
|
// Handlers call ctx.secrets.get(ref); the facade injects the invocation
|
|
@@ -136,6 +146,7 @@ class ContextBuilder {
|
|
|
136
146
|
cache,
|
|
137
147
|
httpClient,
|
|
138
148
|
secrets,
|
|
149
|
+
state,
|
|
139
150
|
stream,
|
|
140
151
|
abortSignal,
|
|
141
152
|
config: this._config
|
|
@@ -164,32 +175,41 @@ class ContextBuilder {
|
|
|
164
175
|
}
|
|
165
176
|
|
|
166
177
|
/**
|
|
178
|
+
* Validate the MQ envelope shape. Every field checked here is supplied by the
|
|
179
|
+
* caller, so every failure is InvalidEnvelopeError (400) naming the key.
|
|
167
180
|
* @private
|
|
168
181
|
*/
|
|
169
182
|
_validateMqMessage(mqMessage) {
|
|
170
183
|
if (!mqMessage || typeof mqMessage !== 'object') {
|
|
171
|
-
throw new
|
|
184
|
+
throw new InvalidEnvelopeError('[ContextBuilder] mqMessage is required - Expected parsed MQ message object');
|
|
172
185
|
}
|
|
173
186
|
if (typeof mqMessage.operation !== 'string' || mqMessage.operation.length === 0) {
|
|
174
|
-
throw new
|
|
187
|
+
throw new InvalidEnvelopeError('[ContextBuilder] mqMessage.operation is required - Expected non-empty string');
|
|
175
188
|
}
|
|
176
189
|
if (!mqMessage.envelope || typeof mqMessage.envelope !== 'object') {
|
|
177
|
-
throw new
|
|
190
|
+
throw new InvalidEnvelopeError(
|
|
178
191
|
'[ContextBuilder] mqMessage.envelope is required - ' +
|
|
179
192
|
'Expected object with tenant_id, workspace_id, optional person_id'
|
|
180
193
|
);
|
|
181
194
|
}
|
|
182
195
|
if (typeof mqMessage.workflow_id !== 'string' || mqMessage.workflow_id.length === 0) {
|
|
183
|
-
throw new
|
|
196
|
+
throw new InvalidEnvelopeError('[ContextBuilder] mqMessage.workflow_id is required - Expected uuid string');
|
|
184
197
|
}
|
|
185
198
|
if (typeof mqMessage.correlation_id !== 'string' || mqMessage.correlation_id.length === 0) {
|
|
186
|
-
throw new
|
|
199
|
+
throw new InvalidEnvelopeError('[ContextBuilder] mqMessage.correlation_id is required - Expected uuid string');
|
|
187
200
|
}
|
|
188
201
|
}
|
|
189
202
|
|
|
190
203
|
/**
|
|
191
204
|
* Enforce bundle_scope tenancy requirements per RFC §5.6 + §6.3 Bug 1 mapping.
|
|
192
205
|
* Tenancy is derived from the MQ envelope only — never from HTTP headers.
|
|
206
|
+
*
|
|
207
|
+
* Two fault owners, two statuses:
|
|
208
|
+
* - `bundleScope` comes from OUR operations.json. An invalid value is a
|
|
209
|
+
* packaging bug, stays an untyped Error and maps to 500.
|
|
210
|
+
* - `envelope.*` comes from the caller / the cookbook author. A missing id
|
|
211
|
+
* is their bug, is thrown as InvalidEnvelopeError and maps to 400 with
|
|
212
|
+
* the missing key named.
|
|
193
213
|
* @private
|
|
194
214
|
*/
|
|
195
215
|
_validateTenancy(bundleScope, envelope) {
|
|
@@ -202,7 +222,7 @@ class ContextBuilder {
|
|
|
202
222
|
|
|
203
223
|
if (bundleScope === 'tenant' || bundleScope === 'workspace') {
|
|
204
224
|
if (envelope.tenant_id === undefined || envelope.tenant_id === null) {
|
|
205
|
-
throw new
|
|
225
|
+
throw new InvalidEnvelopeError(
|
|
206
226
|
`[ContextBuilder] mqMessage.envelope.tenant_id is required for bundle_scope="${bundleScope}" - ` +
|
|
207
227
|
'tenancy must come from the MQ envelope (see RFC §6.3 Bug 1)'
|
|
208
228
|
);
|
|
@@ -210,9 +230,10 @@ class ContextBuilder {
|
|
|
210
230
|
}
|
|
211
231
|
if (bundleScope === 'workspace') {
|
|
212
232
|
if (envelope.workspace_id === undefined || envelope.workspace_id === null) {
|
|
213
|
-
throw new
|
|
233
|
+
throw new InvalidEnvelopeError(
|
|
214
234
|
'[ContextBuilder] mqMessage.envelope.workspace_id is required for bundle_scope="workspace" - ' +
|
|
215
|
-
'workspace-scoped ops must receive a workspace_id in the MQ envelope'
|
|
235
|
+
'workspace-scoped ops must receive a workspace_id in the MQ envelope. ' +
|
|
236
|
+
'Fix: set defaults.workspace_id (or the step-level workspace_id) in the cookbook'
|
|
216
237
|
);
|
|
217
238
|
}
|
|
218
239
|
}
|
package/src/ErrorMapper.js
CHANGED
|
@@ -10,33 +10,32 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Exports:
|
|
12
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
13
|
* - BusinessError — base class for handler-authored errors (code + status required).
|
|
14
|
+
* - ValidationError — 400 VALIDATION_FAILED. Thrown by SchemaValidator
|
|
15
|
+
* (phase: 'input' | 'output') and by handlers.
|
|
16
|
+
* - NotFoundError — 404 RESOURCE_NOT_FOUND.
|
|
17
|
+
* - ConflictError — 409 DUPLICATE_RESOURCE.
|
|
18
|
+
* - BusinessRuleError — 422 BUSINESS_RULE_VIOLATED.
|
|
19
|
+
* - AuthorizationError — 403 FORBIDDEN.
|
|
20
|
+
* - InvalidEnvelopeError — 400 INVALID_ENVELOPE. Thrown by ContextBuilder when
|
|
21
|
+
* the MQ envelope from the caller is malformed.
|
|
22
|
+
* - UnknownOperationError — thrown by HandlerRegistry for unregistered ops.
|
|
16
23
|
* - AbortError — thrown when ctx.abortSignal aborts the invocation.
|
|
24
|
+
*
|
|
25
|
+
* Two constructor shapes, because the parameter sets genuinely differ:
|
|
26
|
+
* - `BusinessError` takes `{ code, status, message, details }` — the author
|
|
27
|
+
* must supply code + status, so they are named.
|
|
28
|
+
* - every named subclass takes `(message, { details, operation })` — code and
|
|
29
|
+
* status are the subclass's own identity and MUST NOT be author-supplied.
|
|
30
|
+
* The subclass shape is deliberately identical to the retired
|
|
31
|
+
* `@onlineapps/service-common` hierarchy so a biz service migrates by changing
|
|
32
|
+
* its import line only.
|
|
33
|
+
*
|
|
34
|
+
* ❌ No subclass carries a default status. A default (service-common used
|
|
35
|
+
* `statusCode = 500`) turns an unset field into a fabricated server error; the
|
|
36
|
+
* base class fail-fasts instead.
|
|
17
37
|
*/
|
|
18
38
|
|
|
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
39
|
class BusinessError extends Error {
|
|
41
40
|
constructor({ code, status, message, details } = {}) {
|
|
42
41
|
if (!code) {
|
|
@@ -56,6 +55,105 @@ class BusinessError extends Error {
|
|
|
56
55
|
}
|
|
57
56
|
}
|
|
58
57
|
|
|
58
|
+
/**
|
|
59
|
+
* 400. Input a caller (or a handler's own field check) rejected.
|
|
60
|
+
*
|
|
61
|
+
* `phase` is set only by SchemaValidator: 'input' means the caller's payload
|
|
62
|
+
* failed the operation schema, 'output' means the handler produced a payload
|
|
63
|
+
* that violates its own contract (a server bug — see map()). A handler-thrown
|
|
64
|
+
* ValidationError has no phase and maps to a plain 400.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} message
|
|
67
|
+
* @param {Object} [options]
|
|
68
|
+
* @param {*} [options.details]
|
|
69
|
+
* @param {'input'|'output'} [options.phase]
|
|
70
|
+
* @param {string} [options.operation]
|
|
71
|
+
*/
|
|
72
|
+
class ValidationError extends BusinessError {
|
|
73
|
+
constructor(message, { details, phase, operation } = {}) {
|
|
74
|
+
super({ code: 'VALIDATION_FAILED', status: 400, message, details });
|
|
75
|
+
this.name = 'ValidationError';
|
|
76
|
+
if (phase !== undefined) {
|
|
77
|
+
this.phase = phase;
|
|
78
|
+
}
|
|
79
|
+
if (operation !== undefined) {
|
|
80
|
+
this.operation = operation;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 404. The addressed resource does not exist. */
|
|
86
|
+
class NotFoundError extends BusinessError {
|
|
87
|
+
constructor(message, { details, operation } = {}) {
|
|
88
|
+
super({ code: 'RESOURCE_NOT_FOUND', status: 404, message, details });
|
|
89
|
+
this.name = 'NotFoundError';
|
|
90
|
+
if (operation !== undefined) {
|
|
91
|
+
this.operation = operation;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 409. The write collides with an existing resource or uniqueness constraint. */
|
|
97
|
+
class ConflictError extends BusinessError {
|
|
98
|
+
constructor(message, { details, operation } = {}) {
|
|
99
|
+
super({ code: 'DUPLICATE_RESOURCE', status: 409, message, details });
|
|
100
|
+
this.name = 'ConflictError';
|
|
101
|
+
if (operation !== undefined) {
|
|
102
|
+
this.operation = operation;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 422. Shape is valid, a domain rule says no. */
|
|
108
|
+
class BusinessRuleError extends BusinessError {
|
|
109
|
+
constructor(message, { details, operation } = {}) {
|
|
110
|
+
super({ code: 'BUSINESS_RULE_VIOLATED', status: 422, message, details });
|
|
111
|
+
this.name = 'BusinessRuleError';
|
|
112
|
+
if (operation !== undefined) {
|
|
113
|
+
this.operation = operation;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 403. The caller is authenticated but not entitled to this resource. */
|
|
119
|
+
class AuthorizationError extends BusinessError {
|
|
120
|
+
constructor(message, { details, operation } = {}) {
|
|
121
|
+
super({ code: 'FORBIDDEN', status: 403, message, details });
|
|
122
|
+
this.name = 'AuthorizationError';
|
|
123
|
+
if (operation !== undefined) {
|
|
124
|
+
this.operation = operation;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* 400. The MQ envelope the caller (or the cookbook author) sent is malformed —
|
|
131
|
+
* a missing tenant_id / workspace_id / workflow_id / correlation_id.
|
|
132
|
+
*
|
|
133
|
+
* Distinct from ValidationError on purpose: VALIDATION_FAILED means the
|
|
134
|
+
* operation's own input schema was violated, INVALID_ENVELOPE means the
|
|
135
|
+
* transport-level context never arrived, so a reader of the log can tell a
|
|
136
|
+
* broken cookbook from a broken payload without opening the message.
|
|
137
|
+
*/
|
|
138
|
+
class InvalidEnvelopeError extends BusinessError {
|
|
139
|
+
constructor(message, { details, operation } = {}) {
|
|
140
|
+
super({ code: 'INVALID_ENVELOPE', status: 400, message, details });
|
|
141
|
+
this.name = 'InvalidEnvelopeError';
|
|
142
|
+
if (operation !== undefined) {
|
|
143
|
+
this.operation = operation;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
class UnknownOperationError extends Error {
|
|
149
|
+
constructor(message) {
|
|
150
|
+
super(message);
|
|
151
|
+
this.name = 'UnknownOperationError';
|
|
152
|
+
this.code = 'UNKNOWN_OPERATION';
|
|
153
|
+
this.status = 404;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
59
157
|
class AbortError extends Error {
|
|
60
158
|
constructor(message) {
|
|
61
159
|
super(message || 'Invocation aborted');
|
|
@@ -115,6 +213,19 @@ class ErrorMapper {
|
|
|
115
213
|
}
|
|
116
214
|
|
|
117
215
|
if (err instanceof BusinessError) {
|
|
216
|
+
// A BusinessError with a 5xx status is a server fault wearing a business
|
|
217
|
+
// class. It is logged like one — `error` level WITH the stack — and the
|
|
218
|
+
// caller gets the same neutral text as any other internal error, so the
|
|
219
|
+
// original message (which may name a tenant, a column or a credential)
|
|
220
|
+
// never leaves the process. Details are dropped for the same reason.
|
|
221
|
+
if (err.status >= 500) {
|
|
222
|
+
this._logger.error(
|
|
223
|
+
'handler raised BusinessError with a server-fault status',
|
|
224
|
+
{ err, operation, correlation_id, code: err.code, status: err.status, stack: err && err.stack }
|
|
225
|
+
);
|
|
226
|
+
return this._buildError(err.status, err.code, 'An internal error occurred', undefined, correlation_id);
|
|
227
|
+
}
|
|
228
|
+
|
|
118
229
|
this._logger.info(
|
|
119
230
|
'handler raised BusinessError',
|
|
120
231
|
{ err, operation, correlation_id, code: err.code, status: err.status }
|
|
@@ -155,8 +266,13 @@ class ErrorMapper {
|
|
|
155
266
|
|
|
156
267
|
module.exports = {
|
|
157
268
|
ErrorMapper,
|
|
269
|
+
BusinessError,
|
|
158
270
|
ValidationError,
|
|
271
|
+
NotFoundError,
|
|
272
|
+
ConflictError,
|
|
273
|
+
BusinessRuleError,
|
|
274
|
+
AuthorizationError,
|
|
275
|
+
InvalidEnvelopeError,
|
|
159
276
|
UnknownOperationError,
|
|
160
|
-
BusinessError,
|
|
161
277
|
AbortError
|
|
162
278
|
};
|
package/src/OperationContext.js
CHANGED
|
@@ -8,7 +8,7 @@ const { ValidationError } = require('./ErrorMapper');
|
|
|
8
8
|
*
|
|
9
9
|
* Contract: RFC `api/docs/architecture/biz-service-invocation-model.md` §5.6.
|
|
10
10
|
*
|
|
11
|
-
* Exactly
|
|
11
|
+
* Exactly 15 named fields, assembled by ContextBuilder.build() once per
|
|
12
12
|
* invocation and frozen before the handler runs:
|
|
13
13
|
*
|
|
14
14
|
* Row-level scoping (from MQ envelope):
|
|
@@ -27,6 +27,8 @@ const { ValidationError } = require('./ErrorMapper');
|
|
|
27
27
|
* cache — CacheAdapter (tenant-namespaced)
|
|
28
28
|
* httpClient — axios instance
|
|
29
29
|
* secrets — { get(name) => Promise<string> }
|
|
30
|
+
* state — StateConnector (persistent Redis state, no TTL,
|
|
31
|
+
* keys namespaced `state:<serviceName>:`) | null
|
|
30
32
|
* stream — (chunk) => Promise<void>
|
|
31
33
|
* abortSignal — AbortSignal
|
|
32
34
|
* config — read-only service config snapshot
|
|
@@ -49,14 +51,14 @@ const { ValidationError } = require('./ErrorMapper');
|
|
|
49
51
|
*/
|
|
50
52
|
class OperationContext {
|
|
51
53
|
/**
|
|
52
|
-
* @param {Object} fields - The
|
|
54
|
+
* @param {Object} fields - The 15 fields listed above, fully populated.
|
|
53
55
|
*/
|
|
54
56
|
constructor(fields) {
|
|
55
57
|
if (!fields || typeof fields !== 'object') {
|
|
56
58
|
throw new Error(
|
|
57
|
-
'[OperationContext] fields is required - Expected object with
|
|
59
|
+
'[OperationContext] fields is required - Expected object with 15 named fields per RFC §5.6 ' +
|
|
58
60
|
'(tenant_id, workspace_id, person_id, operation_name, workflow_id, correlation_id, ' +
|
|
59
|
-
'logger, db, cache, httpClient, secrets, stream, abortSignal, config)'
|
|
61
|
+
'logger, db, cache, httpClient, secrets, state, stream, abortSignal, config)'
|
|
60
62
|
);
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -91,11 +93,10 @@ class OperationContext {
|
|
|
91
93
|
const to = OperationContext._parseBoundary(dateTo, 'dateTo', 'end');
|
|
92
94
|
|
|
93
95
|
if (to.getTime() < from.getTime()) {
|
|
94
|
-
throw new ValidationError(
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
});
|
|
96
|
+
throw new ValidationError(
|
|
97
|
+
'[OperationContext.normalizeDateRange] dateTo is earlier than dateFrom - ' +
|
|
98
|
+
'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
|
|
99
|
+
);
|
|
99
100
|
}
|
|
100
101
|
|
|
101
102
|
return { from, to };
|
|
@@ -110,27 +111,24 @@ class OperationContext {
|
|
|
110
111
|
*/
|
|
111
112
|
static _parseBoundary(value, field, mode) {
|
|
112
113
|
if (value === null || value === undefined) {
|
|
113
|
-
throw new ValidationError(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
});
|
|
114
|
+
throw new ValidationError(
|
|
115
|
+
`[OperationContext.normalizeDateRange] ${field} is null or undefined - ` +
|
|
116
|
+
'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
|
|
117
|
+
);
|
|
118
118
|
}
|
|
119
119
|
if (typeof value !== 'string') {
|
|
120
|
-
throw new ValidationError(
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
});
|
|
120
|
+
throw new ValidationError(
|
|
121
|
+
`[OperationContext.normalizeDateRange] ${field} is not a string - ` +
|
|
122
|
+
'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
|
|
123
|
+
);
|
|
125
124
|
}
|
|
126
125
|
|
|
127
126
|
const trimmed = value.trim();
|
|
128
127
|
if (trimmed.length === 0) {
|
|
129
|
-
throw new ValidationError(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
});
|
|
128
|
+
throw new ValidationError(
|
|
129
|
+
`[OperationContext.normalizeDateRange] ${field} is empty - ` +
|
|
130
|
+
'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
|
|
131
|
+
);
|
|
134
132
|
}
|
|
135
133
|
|
|
136
134
|
const isoDate = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -143,19 +141,17 @@ class OperationContext {
|
|
|
143
141
|
} else if (isoDateTime.test(trimmed)) {
|
|
144
142
|
d = new Date(trimmed);
|
|
145
143
|
} else {
|
|
146
|
-
throw new ValidationError(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
});
|
|
144
|
+
throw new ValidationError(
|
|
145
|
+
`[OperationContext.normalizeDateRange] ${field} is unparseable - ` +
|
|
146
|
+
'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
|
|
147
|
+
);
|
|
151
148
|
}
|
|
152
149
|
|
|
153
150
|
if (isNaN(d.getTime())) {
|
|
154
|
-
throw new ValidationError(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
});
|
|
151
|
+
throw new ValidationError(
|
|
152
|
+
`[OperationContext.normalizeDateRange] ${field} is unparseable - ` +
|
|
153
|
+
'Expected ISO date string (YYYY-MM-DD) or ISO datetime'
|
|
154
|
+
);
|
|
159
155
|
}
|
|
160
156
|
|
|
161
157
|
return d;
|
package/src/SchemaValidator.js
CHANGED
|
@@ -145,8 +145,7 @@ class SchemaValidator {
|
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
const errors = fn.errors || [];
|
|
148
|
-
throw new ValidationError({
|
|
149
|
-
message: _summariseAjvError('input', errors),
|
|
148
|
+
throw new ValidationError(_summariseAjvError('input', errors), {
|
|
150
149
|
phase: 'input',
|
|
151
150
|
operation,
|
|
152
151
|
details: errors
|
|
@@ -180,8 +179,7 @@ class SchemaValidator {
|
|
|
180
179
|
}
|
|
181
180
|
|
|
182
181
|
const errors = fn.errors || [];
|
|
183
|
-
throw new ValidationError({
|
|
184
|
-
message: _summariseAjvError('output', errors),
|
|
182
|
+
throw new ValidationError(_summariseAjvError('output', errors), {
|
|
185
183
|
phase: 'output',
|
|
186
184
|
operation,
|
|
187
185
|
details: errors
|
package/src/index.js
CHANGED
|
@@ -17,6 +17,7 @@ const { ConfigLoader } = require('./ConfigLoader');
|
|
|
17
17
|
const runtimeCfg = require('./config');
|
|
18
18
|
const pkg = require('../package.json');
|
|
19
19
|
const { createTenantContextMiddleware } = require('./createTenantContextMiddleware');
|
|
20
|
+
const { setLogger: setSharedLogger, getLogger: getSharedLogger } = require('./logger');
|
|
20
21
|
|
|
21
22
|
// Note: WorkflowProcessor functionality has been moved to
|
|
22
23
|
// @onlineapps/conn-orch-orchestrator. The former ApiCaller /
|
|
@@ -124,9 +125,14 @@ const { ContextBuilder } = require('./ContextBuilder');
|
|
|
124
125
|
const { SchemaValidator } = require('./SchemaValidator');
|
|
125
126
|
const {
|
|
126
127
|
ErrorMapper,
|
|
128
|
+
BusinessError,
|
|
127
129
|
ValidationError,
|
|
130
|
+
NotFoundError,
|
|
131
|
+
ConflictError,
|
|
132
|
+
BusinessRuleError,
|
|
133
|
+
AuthorizationError,
|
|
134
|
+
InvalidEnvelopeError,
|
|
128
135
|
UnknownOperationError,
|
|
129
|
-
BusinessError,
|
|
130
136
|
AbortError
|
|
131
137
|
} = require('./ErrorMapper');
|
|
132
138
|
|
|
@@ -135,6 +141,12 @@ module.exports.ServiceWrapper = ServiceWrapper;
|
|
|
135
141
|
module.exports.ConfigLoader = ConfigLoader;
|
|
136
142
|
module.exports.bootstrap = bootstrap;
|
|
137
143
|
module.exports.createTenantContextMiddleware = createTenantContextMiddleware;
|
|
144
|
+
|
|
145
|
+
// Shared logger proxy (F15 step 1). Single owner of the late-binding
|
|
146
|
+
// setLogger/getLogger pair that every biz service used to keep as its own
|
|
147
|
+
// `src/lib/logger.js` copy. See src/logger.js for the contract.
|
|
148
|
+
module.exports.setLogger = setSharedLogger;
|
|
149
|
+
module.exports.getLogger = getSharedLogger;
|
|
138
150
|
module.exports.default = ServiceWrapper;
|
|
139
151
|
module.exports.VERSION = pkg.version;
|
|
140
152
|
|
|
@@ -144,7 +156,17 @@ module.exports.OperationContext = OperationContext;
|
|
|
144
156
|
module.exports.ContextBuilder = ContextBuilder;
|
|
145
157
|
module.exports.SchemaValidator = SchemaValidator;
|
|
146
158
|
module.exports.ErrorMapper = ErrorMapper;
|
|
159
|
+
|
|
160
|
+
// Error hierarchy (DÁVKA 29). Single owner of the business-error classes every
|
|
161
|
+
// biz service throws; the retired @onlineapps/service-common hierarchy used the
|
|
162
|
+
// same `(message, options)` signature, so migration is an import-line change.
|
|
163
|
+
// Contract + per-class status/code: src/ErrorMapper.js header.
|
|
164
|
+
module.exports.BusinessError = BusinessError;
|
|
147
165
|
module.exports.ValidationError = ValidationError;
|
|
166
|
+
module.exports.NotFoundError = NotFoundError;
|
|
167
|
+
module.exports.ConflictError = ConflictError;
|
|
168
|
+
module.exports.BusinessRuleError = BusinessRuleError;
|
|
169
|
+
module.exports.AuthorizationError = AuthorizationError;
|
|
170
|
+
module.exports.InvalidEnvelopeError = InvalidEnvelopeError;
|
|
148
171
|
module.exports.UnknownOperationError = UnknownOperationError;
|
|
149
|
-
module.exports.BusinessError = BusinessError;
|
|
150
172
|
module.exports.AbortError = AbortError;
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @module @onlineapps/service-wrapper/logger
|
|
5
|
+
* @description Shared logger proxy for business services.
|
|
6
|
+
*
|
|
7
|
+
* Handlers, services and controllers are `require`d at module load time, long
|
|
8
|
+
* before `wrapper.initialize()` has produced a real logger. This module is the
|
|
9
|
+
* late-binding indirection between the two: bootstrap calls `setLogger()` once,
|
|
10
|
+
* every consumer calls `getLogger()` whenever it actually logs.
|
|
11
|
+
*
|
|
12
|
+
* Fail-fast, no fallback. If `getLogger()` is reached before `setLogger()`, that
|
|
13
|
+
* is a wiring defect and it throws — it does NOT hand back a console-shaped
|
|
14
|
+
* substitute. A silent console fallback makes a service that lost its real
|
|
15
|
+
* logger look healthy: the log lines still appear on stdout, while nothing
|
|
16
|
+
* reaches the monitoring connector, the log files or the correlation context.
|
|
17
|
+
* See `architecture-principles.md` §3 (No Fallbacks) and §4 (Fail-Fast).
|
|
18
|
+
*
|
|
19
|
+
* Consumers call `getLogger()` at call time, never at module scope — binding
|
|
20
|
+
* the result into a module-level constant would capture the state before
|
|
21
|
+
* bootstrap and defeat the indirection.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // bootstrap, once, after wrapper.initialize()
|
|
25
|
+
* const { setLogger } = require('@onlineapps/service-wrapper');
|
|
26
|
+
* setLogger(wrapper.logger);
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* // any consumer
|
|
30
|
+
* const { getLogger } = require('@onlineapps/service-wrapper');
|
|
31
|
+
* function handle() {
|
|
32
|
+
* getLogger().info('Message', { data: 'value' });
|
|
33
|
+
* }
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Methods a logger instance must expose. This is the contract every consumer
|
|
38
|
+
* of `getLogger()` relies on, so it is validated at injection time rather than
|
|
39
|
+
* discovered at the first `logger.debug(...)` in an error path.
|
|
40
|
+
* @type {string[]}
|
|
41
|
+
*/
|
|
42
|
+
const REQUIRED_METHODS = ['info', 'warn', 'error', 'debug'];
|
|
43
|
+
|
|
44
|
+
const FIX_HINT =
|
|
45
|
+
'Fix: pass wrapper.logger to setLogger() once during bootstrap, after wrapper.initialize().';
|
|
46
|
+
|
|
47
|
+
/** @type {Object|null} */
|
|
48
|
+
let _logger = null;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Inject the logger instance. Called once during bootstrap; a later call
|
|
52
|
+
* replaces the instance (tests wire an in-memory logger this way).
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} logger - Logger exposing info/warn/error/debug as functions.
|
|
55
|
+
* @throws {Error} If `logger` is not an object exposing all four methods. The
|
|
56
|
+
* previously stored instance is left untouched when validation fails.
|
|
57
|
+
* @returns {void}
|
|
58
|
+
*/
|
|
59
|
+
function setLogger(logger) {
|
|
60
|
+
if (logger === null || typeof logger !== 'object' || Array.isArray(logger)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`[Logger] setLogger() received ${describe(logger)} - expected an object exposing ` +
|
|
63
|
+
`${REQUIRED_METHODS.join('/')}. ${FIX_HINT}`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const missing = REQUIRED_METHODS.filter((method) => typeof logger[method] !== 'function');
|
|
68
|
+
if (missing.length > 0) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`[Logger] setLogger() received an incomplete logger - missing callable method(s): ` +
|
|
71
|
+
`${missing.join(', ')}. Expected an object exposing ${REQUIRED_METHODS.join('/')}. ${FIX_HINT}`
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_logger = logger;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Return the injected logger.
|
|
80
|
+
*
|
|
81
|
+
* @throws {Error} If `setLogger()` has not run yet. There is no console
|
|
82
|
+
* fallback — an uninitialised logger is a bootstrap defect, not a degraded
|
|
83
|
+
* mode to be papered over.
|
|
84
|
+
* @returns {Object} The logger passed to `setLogger()`.
|
|
85
|
+
*/
|
|
86
|
+
function getLogger() {
|
|
87
|
+
if (_logger === null) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
'[Logger] Logger not initialized - getLogger() was called before setLogger(wrapper.logger). ' +
|
|
90
|
+
'Fix: call setLogger(wrapper.logger) once during bootstrap, after wrapper.initialize(), ' +
|
|
91
|
+
'before any consumer runs.'
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return _logger;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Name the rejected value in the error message, so the caller sees what it
|
|
100
|
+
* actually passed rather than a bare type name.
|
|
101
|
+
*
|
|
102
|
+
* @param {*} value - The rejected argument.
|
|
103
|
+
* @returns {string} Human-readable description.
|
|
104
|
+
* @private
|
|
105
|
+
*/
|
|
106
|
+
function describe(value) {
|
|
107
|
+
if (value === null) {
|
|
108
|
+
return 'null';
|
|
109
|
+
}
|
|
110
|
+
if (Array.isArray(value)) {
|
|
111
|
+
return 'an array';
|
|
112
|
+
}
|
|
113
|
+
return `a value of type "${typeof value}"`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { setLogger, getLogger };
|