@onlineapps/cookbook-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -0
- package/package.json +38 -0
- package/schemas/cookbook.relaxed.schema.json +351 -0
- package/schemas/cookbook.schema.json +482 -0
- package/schemas/cookbook.strict.schema.json +441 -0
- package/src/index.js +60 -0
- package/src/parser/cookbookParser.js +108 -0
- package/src/parser/cookbookValidator.js +569 -0
- package/src/parser.js +7 -0
- package/src/referenceValidator.js +317 -0
- package/src/schema.js +46 -0
- package/src/validator.js +17 -0
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview
|
|
3
|
+
* Comprehensive validator for Workflow Cookbook JSON objects.
|
|
4
|
+
*
|
|
5
|
+
* Validates:
|
|
6
|
+
* - Top-level structure against JSON Schema (strict: no additional properties)
|
|
7
|
+
* - Recursive validation of each Step object, ensuring all required fields are present and no extraneous fields
|
|
8
|
+
* - Unique `id` across all steps (including nested steps in foreach, fork_join, switch, sub_workflow)
|
|
9
|
+
* - For TaskStep and DispatchStep: `input` and `output` objects each have at least one mapping, all values are non-empty strings
|
|
10
|
+
* - For Retry configurations: `maxAttempts` ≥ 1, `delayMs` ≥ 0
|
|
11
|
+
* - For ForeachStep: `iterator` is a non-empty string, `body` is a non-empty array of valid Steps, optional `output` maps have non-empty string values
|
|
12
|
+
* - For ForkJoinStep: `branches` is a non-empty array of Steps, `join.strategy` must be "merge", `join.output` has at least one mapping with non-empty string values
|
|
13
|
+
* - For SwitchStep: `expression` is non-empty string, `cases` has ≥1 key each mapping to a valid Step, optional `default` is a valid Step
|
|
14
|
+
* - For SubWorkflowStep: `input` maps have non-empty string values, `steps` is a non-empty array of valid Steps
|
|
15
|
+
* - For WaitStep: `durationMs` is integer ≥ 0
|
|
16
|
+
* - For DispatchStep: `method` must be "webhook", `target` must be a valid URI, `input` has ≥1 mappings with non-empty string values, optional `retry` as per Retry schema
|
|
17
|
+
* - Ensures no unexpected properties exist on any step object (additionalProperties = false)
|
|
18
|
+
*
|
|
19
|
+
* Throws:
|
|
20
|
+
* - Error with descriptive message upon first validation failure encountered.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* const { validateCookbook } = require('./cookbookValidator');
|
|
24
|
+
* validateCookbook(parsedObject);
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const Ajv = require('ajv');
|
|
28
|
+
const addFormats = require('ajv-formats');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
|
|
32
|
+
// Load schemas dynamically
|
|
33
|
+
const loadSchemaFile = (filename) => {
|
|
34
|
+
const schemaPath = path.join(__dirname, '../../schemas', filename);
|
|
35
|
+
if (fs.existsSync(schemaPath)) {
|
|
36
|
+
return JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
|
|
37
|
+
}
|
|
38
|
+
// Fallback to default schema
|
|
39
|
+
return JSON.parse(fs.readFileSync(path.join(__dirname, '../../schemas/cookbook.schema.json'), 'utf-8'));
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const cookbookSchema = loadSchemaFile('cookbook.schema.json');
|
|
43
|
+
const strictSchema = loadSchemaFile('cookbook.strict.schema.json');
|
|
44
|
+
const relaxedSchema = loadSchemaFile('cookbook.relaxed.schema.json');
|
|
45
|
+
|
|
46
|
+
// Initialize AJV instance for JSON Schema validation
|
|
47
|
+
// Note: strict: false is needed because our schema uses "condition" as a property name,
|
|
48
|
+
// which would be interpreted as an AJV keyword in strict mode
|
|
49
|
+
const ajv = new Ajv({ allErrors: true, strict: false, allowUnionTypes: true });
|
|
50
|
+
addFormats(ajv);
|
|
51
|
+
|
|
52
|
+
// Compile schemas for different modes
|
|
53
|
+
const validators = {
|
|
54
|
+
default: ajv.compile(cookbookSchema),
|
|
55
|
+
strict: ajv.compile(strictSchema),
|
|
56
|
+
relaxed: ajv.compile(relaxedSchema)
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Custom Error class to represent validation errors in the cookbook.
|
|
61
|
+
*/
|
|
62
|
+
class CookbookValidationError extends Error {
|
|
63
|
+
constructor(message) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = 'CookbookValidationError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validate that a given string is non-empty.
|
|
71
|
+
* @param {any} val - Value to check.
|
|
72
|
+
* @param {string} context - Description for error messages.
|
|
73
|
+
*/
|
|
74
|
+
function assertNonEmptyString(val, context) {
|
|
75
|
+
if (typeof val !== 'string' || val.trim() === '') {
|
|
76
|
+
throw new CookbookValidationError(`${context} must be a non-empty string.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validate that a given object has at least one key, and every value is a non-empty string.
|
|
82
|
+
* @param {object} obj - Object to validate.
|
|
83
|
+
* @param {string} context - Description for error messages.
|
|
84
|
+
* @param {boolean} allowEmpty - Whether to allow empty object
|
|
85
|
+
*/
|
|
86
|
+
function assertNonEmptyStringMap(obj, context, allowEmpty = false) {
|
|
87
|
+
if (typeof obj !== 'object' || obj === null) {
|
|
88
|
+
throw new CookbookValidationError(`${context} must be an object.`);
|
|
89
|
+
}
|
|
90
|
+
const keys = Object.keys(obj);
|
|
91
|
+
if (!allowEmpty && keys.length < 1) {
|
|
92
|
+
throw new CookbookValidationError(`${context} must have at least one property.`);
|
|
93
|
+
}
|
|
94
|
+
keys.forEach((k) => {
|
|
95
|
+
const v = obj[k];
|
|
96
|
+
if (typeof v !== 'string' || v.trim() === '') {
|
|
97
|
+
throw new CookbookValidationError(`${context}['${k}'] must be a non-empty string.`);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate Retry configuration: object with integer maxAttempts ≥1 and integer delayMs ≥0, no extra properties.
|
|
104
|
+
* @param {object} retry - Retry object to validate.
|
|
105
|
+
* @param {string} context - Description for error messages.
|
|
106
|
+
*/
|
|
107
|
+
function validateRetryConfig(retry, context) {
|
|
108
|
+
if (typeof retry !== 'object' || retry === null || Array.isArray(retry)) {
|
|
109
|
+
throw new CookbookValidationError(`${context} must be an object.`);
|
|
110
|
+
}
|
|
111
|
+
const allowedProps = ['maxAttempts', 'delayMs'];
|
|
112
|
+
Object.keys(retry).forEach((prop) => {
|
|
113
|
+
if (!allowedProps.includes(prop)) {
|
|
114
|
+
throw new CookbookValidationError(`${context} has unexpected property '${prop}'.`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
if (!Number.isInteger(retry.maxAttempts) || retry.maxAttempts < 1) {
|
|
118
|
+
throw new CookbookValidationError(`${context}.maxAttempts must be an integer ≥ 1.`);
|
|
119
|
+
}
|
|
120
|
+
if (!Number.isInteger(retry.delayMs) || retry.delayMs < 0) {
|
|
121
|
+
throw new CookbookValidationError(`${context}.delayMs must be an integer ≥ 0.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validate a URI string using simple regex for scheme://... . Does not cover every edge case but ensures a scheme and host.
|
|
127
|
+
* @param {string} uri - URI string to validate.
|
|
128
|
+
* @param {string} context - Context for error messages.
|
|
129
|
+
*/
|
|
130
|
+
function assertValidUri(uri, context) {
|
|
131
|
+
// Basic URI regex: scheme://... , e.g. http://example.com or https://api.service/path
|
|
132
|
+
const uriRegex = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/\S+$/;
|
|
133
|
+
if (!uriRegex.test(uri)) {
|
|
134
|
+
throw new CookbookValidationError(`${context} ('${uri}') is not a valid URI.`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Collects all step IDs from a nested structure of steps. Throws if duplicate IDs found.
|
|
140
|
+
* @param {Array} steps - Array of Step objects (may contain nested steps).
|
|
141
|
+
* @param {Set<string>} seenIds - Set to accumulate seen IDs.
|
|
142
|
+
* @param {string} path - Recursion path for error context.
|
|
143
|
+
*/
|
|
144
|
+
function collectAndValidateUniqueIds(steps, seenIds, path) {
|
|
145
|
+
if (!Array.isArray(steps)) {
|
|
146
|
+
throw new CookbookValidationError(`${path} must be an array of steps.`);
|
|
147
|
+
}
|
|
148
|
+
steps.forEach((step, index) => {
|
|
149
|
+
const context = `${path}[${index}]`;
|
|
150
|
+
if (typeof step !== 'object' || step === null) {
|
|
151
|
+
throw new CookbookValidationError(`${context} must be an object.`);
|
|
152
|
+
}
|
|
153
|
+
// id must exist and be a string
|
|
154
|
+
if (!('id' in step)) {
|
|
155
|
+
throw new CookbookValidationError(`${context} is missing required property 'id'.`);
|
|
156
|
+
}
|
|
157
|
+
assertNonEmptyString(step.id, `${context}.id`);
|
|
158
|
+
if (seenIds.has(step.id)) {
|
|
159
|
+
throw new CookbookValidationError(`Duplicate step ID '${step.id}' found at ${context}.`);
|
|
160
|
+
}
|
|
161
|
+
seenIds.add(step.id);
|
|
162
|
+
|
|
163
|
+
// Recurse into nested steps according to type
|
|
164
|
+
const stepType = step.type || 'task';
|
|
165
|
+
switch (stepType) {
|
|
166
|
+
case 'foreach':
|
|
167
|
+
if (!Array.isArray(step.body)) {
|
|
168
|
+
throw new CookbookValidationError(`${context} of type 'foreach' must have 'body' as an array.`);
|
|
169
|
+
}
|
|
170
|
+
collectAndValidateUniqueIds(step.body, seenIds, `${context}.body`);
|
|
171
|
+
break;
|
|
172
|
+
case 'fork_join':
|
|
173
|
+
if (!Array.isArray(step.branches)) {
|
|
174
|
+
throw new CookbookValidationError(`${context} of type 'fork_join' must have 'branches' as an array.`);
|
|
175
|
+
}
|
|
176
|
+
collectAndValidateUniqueIds(step.branches, seenIds, `${context}.branches`);
|
|
177
|
+
break;
|
|
178
|
+
case 'switch':
|
|
179
|
+
// cases: object mapping keys to Step
|
|
180
|
+
if (typeof step.cases === 'object' && step.cases !== null) {
|
|
181
|
+
Object.entries(step.cases).forEach(([caseKey, caseStep]) => {
|
|
182
|
+
collectAndValidateUniqueIds([caseStep], seenIds, `${context}.cases['${caseKey}']`);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if ('default' in step && step.default) {
|
|
186
|
+
collectAndValidateUniqueIds([step.default], seenIds, `${context}.default`);
|
|
187
|
+
}
|
|
188
|
+
break;
|
|
189
|
+
case 'sub_workflow':
|
|
190
|
+
if (!Array.isArray(step.steps)) {
|
|
191
|
+
throw new CookbookValidationError(`${context} of type 'sub_workflow' must have 'steps' as an array.`);
|
|
192
|
+
}
|
|
193
|
+
collectAndValidateUniqueIds(step.steps, seenIds, `${context}.steps`);
|
|
194
|
+
break;
|
|
195
|
+
// task, wait, dispatch have no nested steps for id collection
|
|
196
|
+
case 'task':
|
|
197
|
+
case 'wait':
|
|
198
|
+
case 'dispatch':
|
|
199
|
+
// no nested step arrays
|
|
200
|
+
break;
|
|
201
|
+
default:
|
|
202
|
+
throw new CookbookValidationError(`${context}.type '${stepType}' is not recognized.`);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Validate a single Step object deeply according to its `type`.
|
|
209
|
+
* @param {object} step - The Step object to validate.
|
|
210
|
+
* @param {string} context - String prefix describing location in the cookbook for error context.
|
|
211
|
+
* @param {string} mode - Validation mode (strict, relaxed, default)
|
|
212
|
+
*/
|
|
213
|
+
function validateStep(step, context, mode = 'default') {
|
|
214
|
+
if (typeof step !== 'object' || step === null) {
|
|
215
|
+
throw new CookbookValidationError(`${context} must be an object.`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Validate required properties: id is required, type is optional (defaults to 'task')
|
|
219
|
+
if (!('id' in step)) {
|
|
220
|
+
throw new CookbookValidationError(`${context} is missing required property 'id'.`);
|
|
221
|
+
}
|
|
222
|
+
assertNonEmptyString(step.id, `${context}.id`);
|
|
223
|
+
|
|
224
|
+
// Type is optional, defaults to 'task' for simple service calls
|
|
225
|
+
const stepType = step.type || 'task';
|
|
226
|
+
if (step.type) {
|
|
227
|
+
assertNonEmptyString(step.type, `${context}.type`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Ensure no additional properties beyond { id, type, and those allowed per type }
|
|
231
|
+
const commonAllowed = new Set(['id', 'type']);
|
|
232
|
+
let allowedProps = new Set(commonAllowed);
|
|
233
|
+
|
|
234
|
+
switch (stepType) {
|
|
235
|
+
case 'task': {
|
|
236
|
+
// Required: service (input/output now optional)
|
|
237
|
+
const req = mode === 'strict' ? ['service'] : ['service'];
|
|
238
|
+
req.forEach((prop) => {
|
|
239
|
+
if (!(prop in step)) {
|
|
240
|
+
throw new CookbookValidationError(`${context} of type 'task' is missing required property '${prop}'.`);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
// Validate service
|
|
244
|
+
assertNonEmptyString(step.service, `${context}.service`);
|
|
245
|
+
|
|
246
|
+
// Validate input and output maps - now optional in updated schema
|
|
247
|
+
if ('input' in step) {
|
|
248
|
+
assertNonEmptyStringMap(step.input, `${context}.input`, true);
|
|
249
|
+
}
|
|
250
|
+
if ('output' in step) {
|
|
251
|
+
assertNonEmptyStringMap(step.output, `${context}.output`, true);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Optional properties
|
|
255
|
+
if ('name' in step) {
|
|
256
|
+
assertNonEmptyString(step.name, `${context}.name`);
|
|
257
|
+
}
|
|
258
|
+
if ('action' in step) {
|
|
259
|
+
const validActions = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
260
|
+
if (!validActions.includes(step.action)) {
|
|
261
|
+
throw new CookbookValidationError(`${context}.action must be one of: ${validActions.join(', ')}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if ('endpoint' in step) {
|
|
265
|
+
assertNonEmptyString(step.endpoint, `${context}.endpoint`);
|
|
266
|
+
}
|
|
267
|
+
if ('depends_on' in step) {
|
|
268
|
+
if (!Array.isArray(step.depends_on)) {
|
|
269
|
+
throw new CookbookValidationError(`${context}.depends_on must be an array.`);
|
|
270
|
+
}
|
|
271
|
+
step.depends_on.forEach((dep, idx) => {
|
|
272
|
+
assertNonEmptyString(dep, `${context}.depends_on[${idx}]`);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
if ('retry' in step) {
|
|
276
|
+
validateRetryConfig(step.retry, `${context}.retry`);
|
|
277
|
+
}
|
|
278
|
+
if ('timeoutMs' in step) {
|
|
279
|
+
if (!Number.isInteger(step.timeoutMs) || step.timeoutMs < 1) {
|
|
280
|
+
throw new CookbookValidationError(`${context}.timeoutMs must be an integer ≥ 1.`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
allowedProps = new Set([...commonAllowed, 'name', 'service', 'action', 'endpoint', 'input', 'output', 'depends_on', 'retry', 'timeoutMs']);
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case 'foreach': {
|
|
288
|
+
// Required: iterator, body
|
|
289
|
+
const req = ['iterator', 'body'];
|
|
290
|
+
req.forEach((prop) => {
|
|
291
|
+
if (!(prop in step)) {
|
|
292
|
+
throw new CookbookValidationError(`${context} of type 'foreach' is missing required property '${prop}'.`);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
assertNonEmptyString(step.iterator, `${context}.iterator`);
|
|
296
|
+
|
|
297
|
+
if (!Array.isArray(step.body) || step.body.length < 1) {
|
|
298
|
+
throw new CookbookValidationError(`${context}.body must be a non-empty array of Step objects.`);
|
|
299
|
+
}
|
|
300
|
+
// Recursively validate each nested step in body
|
|
301
|
+
step.body.forEach((childStep, idx) => {
|
|
302
|
+
validateStep(childStep, `${context}.body[${idx}]`, mode);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Optional properties
|
|
306
|
+
if ('name' in step) {
|
|
307
|
+
assertNonEmptyString(step.name, `${context}.name`);
|
|
308
|
+
}
|
|
309
|
+
if ('output' in step) {
|
|
310
|
+
assertNonEmptyStringMap(step.output, `${context}.output`);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
allowedProps = new Set([...commonAllowed, 'name', 'iterator', 'body', 'output']);
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
case 'fork_join': {
|
|
317
|
+
// Required: branches, join
|
|
318
|
+
const req = ['branches', 'join'];
|
|
319
|
+
req.forEach((prop) => {
|
|
320
|
+
if (!(prop in step)) {
|
|
321
|
+
throw new CookbookValidationError(`${context} of type 'fork_join' is missing required property '${prop}'.`);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
if (!Array.isArray(step.branches) || step.branches.length < 1) {
|
|
325
|
+
throw new CookbookValidationError(`${context}.branches must be a non-empty array of Step objects.`);
|
|
326
|
+
}
|
|
327
|
+
// Recursively validate each branch
|
|
328
|
+
step.branches.forEach((branchStep, idx) => {
|
|
329
|
+
validateStep(branchStep, `${context}.branches[${idx}]`, mode);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const join = step.join;
|
|
333
|
+
if (typeof join !== 'object' || join === null) {
|
|
334
|
+
throw new CookbookValidationError(`${context}.join must be an object.`);
|
|
335
|
+
}
|
|
336
|
+
// Validate join.strategy
|
|
337
|
+
const validStrategies = ['merge', 'first', 'last', 'all', 'custom'];
|
|
338
|
+
if (!validStrategies.includes(join.strategy)) {
|
|
339
|
+
throw new CookbookValidationError(`${context}.join.strategy must be one of: ${validStrategies.join(', ')}`);
|
|
340
|
+
}
|
|
341
|
+
// Validate join.output if present (now optional)
|
|
342
|
+
if ('output' in join) {
|
|
343
|
+
assertNonEmptyStringMap(join.output, `${context}.join.output`, true);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Ensure no additional props on join
|
|
347
|
+
const joinAllowed = new Set(['strategy', 'output']);
|
|
348
|
+
Object.keys(join).forEach((prop) => {
|
|
349
|
+
if (!joinAllowed.has(prop)) {
|
|
350
|
+
throw new CookbookValidationError(`${context}.join has unexpected property '${prop}'.`);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Optional properties
|
|
355
|
+
if ('name' in step) {
|
|
356
|
+
assertNonEmptyString(step.name, `${context}.name`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
allowedProps = new Set([...commonAllowed, 'name', 'branches', 'join']);
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case 'switch': {
|
|
363
|
+
// Required: expression, cases, and default in strict mode
|
|
364
|
+
const req = mode === 'strict' ? ['expression', 'cases', 'default'] : ['expression', 'cases'];
|
|
365
|
+
req.forEach((prop) => {
|
|
366
|
+
if (!(prop in step)) {
|
|
367
|
+
throw new CookbookValidationError(`${context} of type 'switch' is missing required property '${prop}'.`);
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
assertNonEmptyString(step.expression, `${context}.expression`);
|
|
371
|
+
|
|
372
|
+
if (typeof step.cases !== 'object' || step.cases === null) {
|
|
373
|
+
throw new CookbookValidationError(`${context}.cases must be an object with ≥1 key.`);
|
|
374
|
+
}
|
|
375
|
+
const caseKeys = Object.keys(step.cases);
|
|
376
|
+
if (caseKeys.length < 1) {
|
|
377
|
+
throw new CookbookValidationError(`${context}.cases must have at least one case.`);
|
|
378
|
+
}
|
|
379
|
+
// Validate each case's Step
|
|
380
|
+
caseKeys.forEach((caseKey) => {
|
|
381
|
+
const caseStep = step.cases[caseKey];
|
|
382
|
+
validateStep(caseStep, `${context}.cases['${caseKey}']`, mode);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
// Optional properties
|
|
386
|
+
if ('name' in step) {
|
|
387
|
+
assertNonEmptyString(step.name, `${context}.name`);
|
|
388
|
+
}
|
|
389
|
+
if ('depends_on' in step) {
|
|
390
|
+
if (!Array.isArray(step.depends_on)) {
|
|
391
|
+
throw new CookbookValidationError(`${context}.depends_on must be an array.`);
|
|
392
|
+
}
|
|
393
|
+
step.depends_on.forEach((dep, idx) => {
|
|
394
|
+
assertNonEmptyString(dep, `${context}.depends_on[${idx}]`);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if ('default' in step) {
|
|
398
|
+
validateStep(step.default, `${context}.default`, mode);
|
|
399
|
+
} else if (mode === 'strict') {
|
|
400
|
+
throw new CookbookValidationError(`${context} of type 'switch' in strict mode must have a 'default' case.`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
allowedProps = new Set([...commonAllowed, 'name', 'expression', 'cases', 'default', 'depends_on']);
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
case 'sub_workflow': {
|
|
407
|
+
// Required: steps (input now optional)
|
|
408
|
+
const req = ['steps'];
|
|
409
|
+
req.forEach((prop) => {
|
|
410
|
+
if (!(prop in step)) {
|
|
411
|
+
throw new CookbookValidationError(`${context} of type 'sub_workflow' is missing required property '${prop}'.`);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
// Validate input map if present
|
|
415
|
+
if ('input' in step) {
|
|
416
|
+
assertNonEmptyStringMap(step.input, `${context}.input`, true);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Validate nested steps
|
|
420
|
+
if (!Array.isArray(step.steps) || step.steps.length < 1) {
|
|
421
|
+
throw new CookbookValidationError(`${context}.steps must be a non-empty array of Step objects.`);
|
|
422
|
+
}
|
|
423
|
+
step.steps.forEach((childStep, idx) => {
|
|
424
|
+
validateStep(childStep, `${context}.steps[${idx}]`, mode);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
// Optional properties
|
|
428
|
+
if ('name' in step) {
|
|
429
|
+
assertNonEmptyString(step.name, `${context}.name`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
allowedProps = new Set([...commonAllowed, 'name', 'input', 'steps']);
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
case 'wait': {
|
|
436
|
+
// Required: durationMs
|
|
437
|
+
if (!('durationMs' in step)) {
|
|
438
|
+
throw new CookbookValidationError(`${context} of type 'wait' is missing required property 'durationMs'.`);
|
|
439
|
+
}
|
|
440
|
+
if (!Number.isInteger(step.durationMs) || step.durationMs < 0) {
|
|
441
|
+
throw new CookbookValidationError(`${context}.durationMs must be an integer ≥ 0.`);
|
|
442
|
+
}
|
|
443
|
+
// Optional properties
|
|
444
|
+
if ('name' in step) {
|
|
445
|
+
assertNonEmptyString(step.name, `${context}.name`);
|
|
446
|
+
}
|
|
447
|
+
allowedProps = new Set([...commonAllowed, 'name', 'durationMs']);
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
case 'dispatch': {
|
|
451
|
+
// Required: method, target (input now optional)
|
|
452
|
+
const req = ['method', 'target'];
|
|
453
|
+
req.forEach((prop) => {
|
|
454
|
+
if (!(prop in step)) {
|
|
455
|
+
throw new CookbookValidationError(`${context} of type 'dispatch' is missing required property '${prop}'.`);
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
// method must be valid
|
|
459
|
+
const validMethods = ['webhook', 'http', 'grpc', 'kafka', 'sqs', 'custom'];
|
|
460
|
+
if (!validMethods.includes(step.method)) {
|
|
461
|
+
throw new CookbookValidationError(`${context}.method must be one of: ${validMethods.join(', ')}`);
|
|
462
|
+
}
|
|
463
|
+
// target must be valid URI
|
|
464
|
+
assertValidUri(step.target, `${context}.target`);
|
|
465
|
+
// input map if present
|
|
466
|
+
if ('input' in step) {
|
|
467
|
+
assertNonEmptyStringMap(step.input, `${context}.input`, true);
|
|
468
|
+
}
|
|
469
|
+
// Optional: retry
|
|
470
|
+
if ('retry' in step) {
|
|
471
|
+
validateRetryConfig(step.retry, `${context}.retry`);
|
|
472
|
+
}
|
|
473
|
+
// Optional properties
|
|
474
|
+
if ('name' in step) {
|
|
475
|
+
assertNonEmptyString(step.name, `${context}.name`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
allowedProps = new Set([...commonAllowed, 'name', 'method', 'target', 'input', 'retry']);
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
default:
|
|
482
|
+
throw new CookbookValidationError(`${context}.type '${stepType}' is not a supported step type.`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Finally, ensure no additional unexpected properties on this step object
|
|
486
|
+
Object.keys(step).forEach((prop) => {
|
|
487
|
+
if (!allowedProps.has(prop)) {
|
|
488
|
+
throw new CookbookValidationError(`${context} has unexpected property '${prop}'.`);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Main entrypoint: Validate an entire workbook JSON object.
|
|
495
|
+
* Throws CookbookValidationError on first error encountered.
|
|
496
|
+
* @param {object} cookbook - Parsed JSON object for the cookbook.
|
|
497
|
+
* @param {object} options - Validation options
|
|
498
|
+
* @param {string} options.mode - Validation mode: 'strict', 'relaxed', or 'default'
|
|
499
|
+
* @param {string} options.schemaVersion - Schema version to use (for migration)
|
|
500
|
+
* @param {boolean} options.warnOnly - If true, log warnings instead of throwing
|
|
501
|
+
*/
|
|
502
|
+
function validateCookbook(cookbook, options = {}) {
|
|
503
|
+
const mode = options.mode || 'default';
|
|
504
|
+
const warnOnly = options.warnOnly || false;
|
|
505
|
+
// Top-level type must be object
|
|
506
|
+
if (typeof cookbook !== 'object' || cookbook === null || Array.isArray(cookbook)) {
|
|
507
|
+
throw new CookbookValidationError(`Cookbook must be a JSON object.`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Select validator based on mode
|
|
511
|
+
const validateSchema = validators[mode] || validators.default;
|
|
512
|
+
|
|
513
|
+
// Validate against JSON Schema (structure, required top-level props, basic types)
|
|
514
|
+
const valid = validateSchema(cookbook);
|
|
515
|
+
if (!valid) {
|
|
516
|
+
// Collect all errors for better debugging
|
|
517
|
+
const errors = validateSchema.errors.map(err => {
|
|
518
|
+
return `${err.instancePath || 'cookbook'} ${err.message}`;
|
|
519
|
+
});
|
|
520
|
+
const message = `Schema validation errors (${mode} mode):\n${errors.join('\n')}`;
|
|
521
|
+
|
|
522
|
+
if (warnOnly) {
|
|
523
|
+
console.warn(`WARNING: ${message}`);
|
|
524
|
+
} else {
|
|
525
|
+
throw new CookbookValidationError(message);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Collect and validate unique IDs across all nested steps
|
|
530
|
+
const seenIds = new Set();
|
|
531
|
+
collectAndValidateUniqueIds(cookbook.steps, seenIds, 'steps');
|
|
532
|
+
|
|
533
|
+
// Validate each top-level Step deeply (skip in relaxed mode)
|
|
534
|
+
if (mode !== 'relaxed') {
|
|
535
|
+
cookbook.steps.forEach((step, idx) => {
|
|
536
|
+
try {
|
|
537
|
+
validateStep(step, `steps[${idx}]`, mode);
|
|
538
|
+
} catch (err) {
|
|
539
|
+
if (warnOnly) {
|
|
540
|
+
console.warn(`WARNING: ${err.message}`);
|
|
541
|
+
} else {
|
|
542
|
+
throw err;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Validate references if not in relaxed mode
|
|
549
|
+
if (mode !== 'relaxed' && options.validateReferences !== false) {
|
|
550
|
+
try {
|
|
551
|
+
const { validateAllReferences } = require('../referenceValidator');
|
|
552
|
+
validateAllReferences(cookbook);
|
|
553
|
+
} catch (err) {
|
|
554
|
+
if (warnOnly) {
|
|
555
|
+
console.warn(`WARNING: Reference validation error: ${err.message}`);
|
|
556
|
+
} else {
|
|
557
|
+
throw new CookbookValidationError(`Reference validation error: ${err.message}`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// If we reach this point, the cookbook is valid
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
module.exports = {
|
|
566
|
+
validateCookbook,
|
|
567
|
+
validateStep,
|
|
568
|
+
CookbookValidationError,
|
|
569
|
+
};
|