x-openapi-flow 1.1.1 → 1.1.3
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 +34 -3
- package/bin/x-openapi-flow.js +428 -62
- package/examples/non-terminating-api.yaml +4 -4
- package/examples/order-api.yaml +14 -6
- package/examples/payment-api.yaml +3 -3
- package/examples/quality-warning-api.yaml +4 -4
- package/examples/swagger-ui/index.html +30 -0
- package/examples/swagger-ui/x-openapi-flow-plugin.js +46 -0
- package/examples/ticket-api.yaml +6 -6
- package/lib/validator.js +70 -14
- package/package.json +2 -2
- package/schema/flow-schema.json +15 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# x-openapi-flow
|
|
2
2
|
|
|
3
|
-
CLI and specification for validating the `x-flow` extension field in OpenAPI documents.
|
|
3
|
+
CLI and specification for validating the `x-openapi-flow` extension field in OpenAPI documents.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
@@ -20,11 +20,25 @@ x-openapi-flow doctor
|
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
x-openapi-flow validate <openapi-file> [--format pretty|json] [--profile core|relaxed|strict] [--strict-quality] [--config path]
|
|
23
|
-
x-openapi-flow init [
|
|
23
|
+
x-openapi-flow init [openapi-file] [--flows path]
|
|
24
|
+
x-openapi-flow apply [openapi-file] [--flows path] [--out path]
|
|
24
25
|
x-openapi-flow graph <openapi-file> [--format mermaid|json]
|
|
25
26
|
x-openapi-flow doctor [--config path]
|
|
26
27
|
```
|
|
27
28
|
|
|
29
|
+
`init` always works on an existing OpenAPI file in your repository.
|
|
30
|
+
`init` creates/synchronizes `x-openapi-flow.flows.yaml` as a persistent sidecar for your `x-openapi-flow` data.
|
|
31
|
+
Use `apply` to inject sidecar flows back into regenerated OpenAPI files.
|
|
32
|
+
If no OpenAPI/Swagger file exists yet, generate one first with your framework's official OpenAPI/Swagger tooling.
|
|
33
|
+
|
|
34
|
+
## Recommended Workflow
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
x-openapi-flow init openapi.yaml
|
|
38
|
+
# edit x-openapi-flow.flows.yaml
|
|
39
|
+
x-openapi-flow apply openapi.yaml
|
|
40
|
+
```
|
|
41
|
+
|
|
28
42
|
## Optional Configuration
|
|
29
43
|
|
|
30
44
|
Create `x-openapi-flow.config.json` in your project directory:
|
|
@@ -40,7 +54,24 @@ Create `x-openapi-flow.config.json` in your project directory:
|
|
|
40
54
|
## File Compatibility
|
|
41
55
|
|
|
42
56
|
- OpenAPI input in `.yaml`, `.yml`, and `.json`
|
|
43
|
-
- Validation processes OAS content with the `x-flow` extension
|
|
57
|
+
- Validation processes OAS content with the `x-openapi-flow` extension
|
|
58
|
+
|
|
59
|
+
### Optional Transition Guidance Fields
|
|
60
|
+
|
|
61
|
+
- `next_operation_id`: operationId usually called for the next state transition
|
|
62
|
+
- `prerequisite_operation_ids`: operationIds expected before a transition
|
|
63
|
+
|
|
64
|
+
## Swagger UI
|
|
65
|
+
|
|
66
|
+
- There is no Swagger UI-based automated test in this repo today (tests are CLI-only).
|
|
67
|
+
- For UI interpretation of `x-openapi-flow`, use `showExtensions: true` plus the example plugin at `examples/swagger-ui/x-openapi-flow-plugin.js`.
|
|
68
|
+
- A ready HTML example is available at `examples/swagger-ui/index.html`.
|
|
69
|
+
|
|
70
|
+
## Graph Output Example
|
|
71
|
+
|
|
72
|
+
`x-openapi-flow graph` includes transition guidance labels in Mermaid output when present (`next_operation_id`, `prerequisite_operation_ids`).
|
|
73
|
+
|
|
74
|
+

|
|
44
75
|
|
|
45
76
|
## Repository and Full Documentation
|
|
46
77
|
|
package/bin/x-openapi-flow.js
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
const fs = require("fs");
|
|
5
5
|
const path = require("path");
|
|
6
|
+
const yaml = require("js-yaml");
|
|
6
7
|
const {
|
|
7
8
|
run,
|
|
8
9
|
loadApi,
|
|
9
10
|
extractFlows,
|
|
10
|
-
buildStateGraph,
|
|
11
11
|
} = require("../lib/validator");
|
|
12
12
|
|
|
13
13
|
const DEFAULT_CONFIG_NAME = "x-openapi-flow.config.json";
|
|
14
|
+
const DEFAULT_FLOWS_FILE = "x-openapi-flow.flows.yaml";
|
|
14
15
|
|
|
15
16
|
function resolveConfigPath(configPathArg) {
|
|
16
17
|
return configPathArg
|
|
@@ -42,7 +43,8 @@ function printHelp() {
|
|
|
42
43
|
|
|
43
44
|
Usage:
|
|
44
45
|
x-openapi-flow validate <openapi-file> [--format pretty|json] [--profile core|relaxed|strict] [--strict-quality] [--config path]
|
|
45
|
-
x-openapi-flow init [
|
|
46
|
+
x-openapi-flow init [openapi-file] [--flows path]
|
|
47
|
+
x-openapi-flow apply [openapi-file] [--flows path] [--out path]
|
|
46
48
|
x-openapi-flow graph <openapi-file> [--format mermaid|json]
|
|
47
49
|
x-openapi-flow doctor [--config path]
|
|
48
50
|
x-openapi-flow --help
|
|
@@ -51,7 +53,10 @@ Examples:
|
|
|
51
53
|
x-openapi-flow validate examples/order-api.yaml
|
|
52
54
|
x-openapi-flow validate examples/order-api.yaml --profile relaxed
|
|
53
55
|
x-openapi-flow validate examples/order-api.yaml --strict-quality
|
|
54
|
-
x-openapi-flow init
|
|
56
|
+
x-openapi-flow init openapi.yaml --flows x-openapi-flow.flows.yaml
|
|
57
|
+
x-openapi-flow init
|
|
58
|
+
x-openapi-flow apply openapi.yaml
|
|
59
|
+
x-openapi-flow apply openapi.yaml --out openapi.flow.yaml
|
|
55
60
|
x-openapi-flow graph examples/order-api.yaml
|
|
56
61
|
x-openapi-flow doctor
|
|
57
62
|
`);
|
|
@@ -163,21 +168,21 @@ function parseValidateArgs(args) {
|
|
|
163
168
|
}
|
|
164
169
|
|
|
165
170
|
function parseInitArgs(args) {
|
|
166
|
-
const unknown = findUnknownOptions(args, ["--
|
|
171
|
+
const unknown = findUnknownOptions(args, ["--flows"], []);
|
|
167
172
|
if (unknown) {
|
|
168
173
|
return { error: `Unknown option: ${unknown}` };
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
const
|
|
172
|
-
if (
|
|
173
|
-
return { error:
|
|
176
|
+
const flowsOpt = getOptionValue(args, "--flows");
|
|
177
|
+
if (flowsOpt.error) {
|
|
178
|
+
return { error: flowsOpt.error };
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
const positional = args.filter((token, index) => {
|
|
177
|
-
if (token === "--
|
|
182
|
+
if (token === "--flows") {
|
|
178
183
|
return false;
|
|
179
184
|
}
|
|
180
|
-
if (index > 0 && args[index - 1] === "--
|
|
185
|
+
if (index > 0 && args[index - 1] === "--flows") {
|
|
181
186
|
return false;
|
|
182
187
|
}
|
|
183
188
|
return !token.startsWith("--");
|
|
@@ -188,8 +193,45 @@ function parseInitArgs(args) {
|
|
|
188
193
|
}
|
|
189
194
|
|
|
190
195
|
return {
|
|
191
|
-
|
|
192
|
-
|
|
196
|
+
openApiFile: positional[0] ? path.resolve(positional[0]) : undefined,
|
|
197
|
+
flowsPath: flowsOpt.found ? path.resolve(flowsOpt.value) : undefined,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function parseApplyArgs(args) {
|
|
202
|
+
const unknown = findUnknownOptions(args, ["--flows", "--out"], []);
|
|
203
|
+
if (unknown) {
|
|
204
|
+
return { error: `Unknown option: ${unknown}` };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const flowsOpt = getOptionValue(args, "--flows");
|
|
208
|
+
if (flowsOpt.error) {
|
|
209
|
+
return { error: flowsOpt.error };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const outOpt = getOptionValue(args, "--out");
|
|
213
|
+
if (outOpt.error) {
|
|
214
|
+
return { error: outOpt.error };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const positional = args.filter((token, index) => {
|
|
218
|
+
if (token === "--flows" || token === "--out") {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
if (index > 0 && (args[index - 1] === "--flows" || args[index - 1] === "--out")) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
return !token.startsWith("--");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (positional.length > 1) {
|
|
228
|
+
return { error: `Unexpected argument: ${positional[1]}` };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
openApiFile: positional[0] ? path.resolve(positional[0]) : undefined,
|
|
233
|
+
flowsPath: flowsOpt.found ? path.resolve(flowsOpt.value) : undefined,
|
|
234
|
+
outPath: outOpt.found ? path.resolve(outOpt.value) : undefined,
|
|
193
235
|
};
|
|
194
236
|
}
|
|
195
237
|
|
|
@@ -274,6 +316,11 @@ function parseArgs(argv) {
|
|
|
274
316
|
return parsed.error ? parsed : { command, ...parsed };
|
|
275
317
|
}
|
|
276
318
|
|
|
319
|
+
if (command === "apply") {
|
|
320
|
+
const parsed = parseApplyArgs(commandArgs);
|
|
321
|
+
return parsed.error ? parsed : { command, ...parsed };
|
|
322
|
+
}
|
|
323
|
+
|
|
277
324
|
if (command === "doctor") {
|
|
278
325
|
const parsed = parseDoctorArgs(commandArgs);
|
|
279
326
|
return parsed.error ? parsed : { command, ...parsed };
|
|
@@ -282,51 +329,330 @@ function parseArgs(argv) {
|
|
|
282
329
|
return { error: `Unknown command: ${command}` };
|
|
283
330
|
}
|
|
284
331
|
|
|
285
|
-
function
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
332
|
+
function findOpenApiFile(startDirectory) {
|
|
333
|
+
const preferredNames = [
|
|
334
|
+
"openapi.yaml",
|
|
335
|
+
"openapi.yml",
|
|
336
|
+
"openapi.json",
|
|
337
|
+
"swagger.yaml",
|
|
338
|
+
"swagger.yml",
|
|
339
|
+
"swagger.json",
|
|
340
|
+
];
|
|
341
|
+
|
|
342
|
+
for (const fileName of preferredNames) {
|
|
343
|
+
const candidate = path.join(startDirectory, fileName);
|
|
344
|
+
if (fs.existsSync(candidate)) {
|
|
345
|
+
return candidate;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const ignoredDirs = new Set(["node_modules", ".git", "dist", "build"]);
|
|
350
|
+
|
|
351
|
+
function walk(directory) {
|
|
352
|
+
let entries = [];
|
|
353
|
+
try {
|
|
354
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
355
|
+
} catch (_err) {
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
const fullPath = path.join(directory, entry.name);
|
|
361
|
+
|
|
362
|
+
if (entry.isDirectory()) {
|
|
363
|
+
if (!ignoredDirs.has(entry.name)) {
|
|
364
|
+
const nested = walk(fullPath);
|
|
365
|
+
if (nested) {
|
|
366
|
+
return nested;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (preferredNames.includes(entry.name)) {
|
|
373
|
+
return fullPath;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return walk(startDirectory);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function resolveFlowsPath(openApiFile, customFlowsPath) {
|
|
384
|
+
if (customFlowsPath) {
|
|
385
|
+
return customFlowsPath;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (openApiFile) {
|
|
389
|
+
return path.join(path.dirname(openApiFile), DEFAULT_FLOWS_FILE);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return path.resolve(process.cwd(), DEFAULT_FLOWS_FILE);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function getOpenApiFormat(filePath) {
|
|
396
|
+
return filePath.endsWith(".json") ? "json" : "yaml";
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function saveOpenApi(filePath, api) {
|
|
400
|
+
const format = getOpenApiFormat(filePath);
|
|
401
|
+
const content = format === "json"
|
|
402
|
+
? JSON.stringify(api, null, 2) + "\n"
|
|
403
|
+
: yaml.dump(api, { noRefs: true, lineWidth: -1 });
|
|
404
|
+
|
|
405
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
406
|
+
fs.writeFileSync(filePath, content, "utf8");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function extractOperationEntries(api) {
|
|
410
|
+
const entries = [];
|
|
411
|
+
const paths = (api && api.paths) || {};
|
|
412
|
+
const httpMethods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
413
|
+
|
|
414
|
+
for (const [pathKey, pathItem] of Object.entries(paths)) {
|
|
415
|
+
for (const method of httpMethods) {
|
|
416
|
+
const operation = pathItem[method];
|
|
417
|
+
if (!operation) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const operationId = operation.operationId;
|
|
422
|
+
const key = operationId ? `operationId:${operationId}` : `${method.toUpperCase()} ${pathKey}`;
|
|
423
|
+
|
|
424
|
+
entries.push({
|
|
425
|
+
key,
|
|
426
|
+
operationId,
|
|
427
|
+
method,
|
|
428
|
+
path: pathKey,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return entries;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function readFlowsFile(flowsPath) {
|
|
437
|
+
if (!fs.existsSync(flowsPath)) {
|
|
438
|
+
return {
|
|
439
|
+
version: "1.0",
|
|
440
|
+
operations: [],
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const content = fs.readFileSync(flowsPath, "utf8");
|
|
445
|
+
const parsed = yaml.load(content);
|
|
446
|
+
|
|
447
|
+
if (!parsed || typeof parsed !== "object") {
|
|
448
|
+
return { version: "1.0", operations: [] };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
version: parsed.version || "1.0",
|
|
453
|
+
operations: Array.isArray(parsed.operations) ? parsed.operations : [],
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function writeFlowsFile(flowsPath, flowsDoc) {
|
|
458
|
+
fs.mkdirSync(path.dirname(flowsPath), { recursive: true });
|
|
459
|
+
const content = yaml.dump(flowsDoc, { noRefs: true, lineWidth: -1 });
|
|
460
|
+
fs.writeFileSync(flowsPath, content, "utf8");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function buildOperationLookup(api) {
|
|
464
|
+
const lookupByKey = new Map();
|
|
465
|
+
const lookupByOperationId = new Map();
|
|
466
|
+
const entries = extractOperationEntries(api);
|
|
467
|
+
|
|
468
|
+
for (const entry of entries) {
|
|
469
|
+
lookupByKey.set(entry.key, entry);
|
|
470
|
+
if (entry.operationId) {
|
|
471
|
+
lookupByOperationId.set(entry.operationId, entry);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return { entries, lookupByKey, lookupByOperationId };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function mergeFlowsWithOpenApi(api, flowsDoc) {
|
|
479
|
+
const { entries, lookupByKey, lookupByOperationId } = buildOperationLookup(api);
|
|
480
|
+
|
|
481
|
+
const existingByKey = new Map();
|
|
482
|
+
for (const entry of flowsDoc.operations) {
|
|
483
|
+
const entryKey = entry.key || (entry.operationId ? `operationId:${entry.operationId}` : null);
|
|
484
|
+
if (entryKey) {
|
|
485
|
+
existingByKey.set(entryKey, entry);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const mergedOperations = [];
|
|
490
|
+
|
|
491
|
+
for (const op of entries) {
|
|
492
|
+
const existing = existingByKey.get(op.key);
|
|
493
|
+
if (existing) {
|
|
494
|
+
mergedOperations.push({
|
|
495
|
+
...existing,
|
|
496
|
+
key: op.key,
|
|
497
|
+
operationId: op.operationId,
|
|
498
|
+
method: op.method,
|
|
499
|
+
path: op.path,
|
|
500
|
+
missing_in_openapi: false,
|
|
501
|
+
});
|
|
502
|
+
} else {
|
|
503
|
+
mergedOperations.push({
|
|
504
|
+
key: op.key,
|
|
505
|
+
operationId: op.operationId,
|
|
506
|
+
method: op.method,
|
|
507
|
+
path: op.path,
|
|
508
|
+
"x-openapi-flow": null,
|
|
509
|
+
missing_in_openapi: false,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
for (const existing of flowsDoc.operations) {
|
|
515
|
+
const existingKey = existing.key || (existing.operationId ? `operationId:${existing.operationId}` : null);
|
|
516
|
+
const found = existingKey && lookupByKey.has(existingKey);
|
|
517
|
+
|
|
518
|
+
if (!found) {
|
|
519
|
+
mergedOperations.push({
|
|
520
|
+
...existing,
|
|
521
|
+
missing_in_openapi: true,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
version: "1.0",
|
|
528
|
+
operations: mergedOperations,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function applyFlowsToOpenApi(api, flowsDoc) {
|
|
533
|
+
const paths = (api && api.paths) || {};
|
|
534
|
+
let appliedCount = 0;
|
|
535
|
+
const { lookupByKey, lookupByOperationId } = buildOperationLookup(api);
|
|
536
|
+
|
|
537
|
+
for (const flowEntry of flowsDoc.operations || []) {
|
|
538
|
+
if (!flowEntry || flowEntry.missing_in_openapi === true) {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const flowValue = flowEntry["x-openapi-flow"];
|
|
543
|
+
if (!flowValue || typeof flowValue !== "object") {
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
let target = null;
|
|
548
|
+
if (flowEntry.operationId && lookupByOperationId.has(flowEntry.operationId)) {
|
|
549
|
+
target = lookupByOperationId.get(flowEntry.operationId);
|
|
550
|
+
} else if (flowEntry.key && lookupByKey.has(flowEntry.key)) {
|
|
551
|
+
target = lookupByKey.get(flowEntry.key);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (!target) {
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const pathItem = paths[target.path];
|
|
559
|
+
if (pathItem && pathItem[target.method]) {
|
|
560
|
+
pathItem[target.method]["x-openapi-flow"] = flowValue;
|
|
561
|
+
appliedCount += 1;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return appliedCount;
|
|
319
566
|
}
|
|
320
567
|
|
|
321
568
|
function runInit(parsed) {
|
|
322
|
-
|
|
323
|
-
|
|
569
|
+
const targetOpenApiFile = parsed.openApiFile || findOpenApiFile(process.cwd());
|
|
570
|
+
|
|
571
|
+
if (!targetOpenApiFile) {
|
|
572
|
+
console.error("ERROR: Could not find an existing OpenAPI file in this repository.");
|
|
573
|
+
console.error("Expected one of: openapi.yaml|yml|json, swagger.yaml|yml|json");
|
|
574
|
+
console.error("Create your OpenAPI/Swagger spec first (using your framework's official generator), then run init again.");
|
|
575
|
+
return 1;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const flowsPath = resolveFlowsPath(targetOpenApiFile, parsed.flowsPath);
|
|
579
|
+
|
|
580
|
+
let api;
|
|
581
|
+
try {
|
|
582
|
+
api = loadApi(targetOpenApiFile);
|
|
583
|
+
} catch (err) {
|
|
584
|
+
console.error(`ERROR: Could not parse OpenAPI file — ${err.message}`);
|
|
585
|
+
return 1;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
let flowsDoc;
|
|
589
|
+
try {
|
|
590
|
+
flowsDoc = readFlowsFile(flowsPath);
|
|
591
|
+
} catch (err) {
|
|
592
|
+
console.error(`ERROR: Could not parse flows file — ${err.message}`);
|
|
324
593
|
return 1;
|
|
325
594
|
}
|
|
326
595
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
596
|
+
const mergedFlows = mergeFlowsWithOpenApi(api, flowsDoc);
|
|
597
|
+
writeFlowsFile(flowsPath, mergedFlows);
|
|
598
|
+
const appliedCount = applyFlowsToOpenApi(api, mergedFlows);
|
|
599
|
+
saveOpenApi(targetOpenApiFile, api);
|
|
600
|
+
|
|
601
|
+
const trackedCount = mergedFlows.operations.filter((entry) => !entry.missing_in_openapi).length;
|
|
602
|
+
const orphanCount = mergedFlows.operations.filter((entry) => entry.missing_in_openapi).length;
|
|
603
|
+
|
|
604
|
+
console.log(`Using existing OpenAPI file: ${targetOpenApiFile}`);
|
|
605
|
+
console.log(`Flows sidecar synced: ${flowsPath}`);
|
|
606
|
+
console.log(`Tracked operations: ${trackedCount}`);
|
|
607
|
+
if (orphanCount > 0) {
|
|
608
|
+
console.log(`Orphan flow entries kept in sidecar: ${orphanCount}`);
|
|
609
|
+
}
|
|
610
|
+
console.log(`Applied x-openapi-flow entries to OpenAPI: ${appliedCount}`);
|
|
611
|
+
|
|
612
|
+
console.log(`Validate now: x-openapi-flow validate ${targetOpenApiFile}`);
|
|
613
|
+
return 0;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function runApply(parsed) {
|
|
617
|
+
const targetOpenApiFile = parsed.openApiFile || findOpenApiFile(process.cwd());
|
|
618
|
+
|
|
619
|
+
if (!targetOpenApiFile) {
|
|
620
|
+
console.error("ERROR: Could not find an existing OpenAPI file in this repository.");
|
|
621
|
+
console.error("Expected one of: openapi.yaml|yml|json, swagger.yaml|yml|json");
|
|
622
|
+
return 1;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const flowsPath = resolveFlowsPath(targetOpenApiFile, parsed.flowsPath);
|
|
626
|
+
if (!fs.existsSync(flowsPath)) {
|
|
627
|
+
console.error(`ERROR: Flows sidecar not found: ${flowsPath}`);
|
|
628
|
+
console.error("Run `x-openapi-flow init` first to create and sync the sidecar.");
|
|
629
|
+
return 1;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
let api;
|
|
633
|
+
let flowsDoc;
|
|
634
|
+
try {
|
|
635
|
+
api = loadApi(targetOpenApiFile);
|
|
636
|
+
} catch (err) {
|
|
637
|
+
console.error(`ERROR: Could not parse OpenAPI file — ${err.message}`);
|
|
638
|
+
return 1;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
flowsDoc = readFlowsFile(flowsPath);
|
|
643
|
+
} catch (err) {
|
|
644
|
+
console.error(`ERROR: Could not parse flows file — ${err.message}`);
|
|
645
|
+
return 1;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const appliedCount = applyFlowsToOpenApi(api, flowsDoc);
|
|
649
|
+
const outputPath = parsed.outPath || targetOpenApiFile;
|
|
650
|
+
saveOpenApi(outputPath, api);
|
|
651
|
+
|
|
652
|
+
console.log(`OpenAPI source: ${targetOpenApiFile}`);
|
|
653
|
+
console.log(`Flows sidecar: ${flowsPath}`);
|
|
654
|
+
console.log(`Applied x-openapi-flow entries: ${appliedCount}`);
|
|
655
|
+
console.log(`Output written to: ${outputPath}`);
|
|
330
656
|
return 0;
|
|
331
657
|
}
|
|
332
658
|
|
|
@@ -375,25 +701,61 @@ function runDoctor(parsed) {
|
|
|
375
701
|
function buildMermaidGraph(filePath) {
|
|
376
702
|
const api = loadApi(filePath);
|
|
377
703
|
const flows = extractFlows(api);
|
|
378
|
-
const graph = buildStateGraph(flows);
|
|
379
704
|
const lines = ["stateDiagram-v2"];
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
705
|
+
const nodes = new Set();
|
|
706
|
+
const edges = [];
|
|
707
|
+
const edgeSeen = new Set();
|
|
708
|
+
|
|
709
|
+
for (const { flow } of flows) {
|
|
710
|
+
nodes.add(flow.current_state);
|
|
711
|
+
|
|
712
|
+
const transitions = flow.transitions || [];
|
|
713
|
+
for (const transition of transitions) {
|
|
714
|
+
const from = flow.current_state;
|
|
715
|
+
const to = transition.target_state;
|
|
716
|
+
if (!to) {
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
nodes.add(to);
|
|
721
|
+
|
|
722
|
+
const labelParts = [];
|
|
723
|
+
if (transition.next_operation_id) {
|
|
724
|
+
labelParts.push(`next:${transition.next_operation_id}`);
|
|
725
|
+
}
|
|
726
|
+
if (
|
|
727
|
+
Array.isArray(transition.prerequisite_operation_ids) &&
|
|
728
|
+
transition.prerequisite_operation_ids.length > 0
|
|
729
|
+
) {
|
|
730
|
+
labelParts.push(`requires:${transition.prerequisite_operation_ids.join(",")}`);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const label = labelParts.join(" | ");
|
|
734
|
+
const edgeKey = `${from}::${to}::${label}`;
|
|
735
|
+
if (edgeSeen.has(edgeKey)) {
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
edgeSeen.add(edgeKey);
|
|
740
|
+
edges.push({
|
|
741
|
+
from,
|
|
742
|
+
to,
|
|
743
|
+
next_operation_id: transition.next_operation_id,
|
|
744
|
+
prerequisite_operation_ids: transition.prerequisite_operation_ids || [],
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
lines.push(` ${from} --> ${to}${label ? `: ${label}` : ""}`);
|
|
748
|
+
}
|
|
383
749
|
}
|
|
384
750
|
|
|
385
|
-
for (const
|
|
386
|
-
|
|
387
|
-
lines.push(` ${from} --> ${to}`);
|
|
388
|
-
}
|
|
751
|
+
for (const state of nodes) {
|
|
752
|
+
lines.splice(1, 0, ` state ${state}`);
|
|
389
753
|
}
|
|
390
754
|
|
|
391
755
|
return {
|
|
392
756
|
flowCount: flows.length,
|
|
393
|
-
nodes: [...
|
|
394
|
-
edges
|
|
395
|
-
[...targets].map((to) => ({ from, to }))
|
|
396
|
-
),
|
|
757
|
+
nodes: [...nodes],
|
|
758
|
+
edges,
|
|
397
759
|
mermaid: lines.join("\n"),
|
|
398
760
|
};
|
|
399
761
|
}
|
|
@@ -440,6 +802,10 @@ function main() {
|
|
|
440
802
|
process.exit(runGraph(parsed));
|
|
441
803
|
}
|
|
442
804
|
|
|
805
|
+
if (parsed.command === "apply") {
|
|
806
|
+
process.exit(runApply(parsed));
|
|
807
|
+
}
|
|
808
|
+
|
|
443
809
|
const config = loadConfig(parsed.configPath);
|
|
444
810
|
if (config.error) {
|
|
445
811
|
console.error(`ERROR: ${config.error}`);
|
|
@@ -10,7 +10,7 @@ paths:
|
|
|
10
10
|
post:
|
|
11
11
|
summary: Start flow
|
|
12
12
|
operationId: startFlow
|
|
13
|
-
x-flow:
|
|
13
|
+
x-openapi-flow:
|
|
14
14
|
version: "1.0"
|
|
15
15
|
id: start-flow
|
|
16
16
|
current_state: START
|
|
@@ -29,7 +29,7 @@ paths:
|
|
|
29
29
|
post:
|
|
30
30
|
summary: Loop A
|
|
31
31
|
operationId: loopA
|
|
32
|
-
x-flow:
|
|
32
|
+
x-openapi-flow:
|
|
33
33
|
version: "1.0"
|
|
34
34
|
id: loop-a-flow
|
|
35
35
|
current_state: LOOP_A
|
|
@@ -45,7 +45,7 @@ paths:
|
|
|
45
45
|
post:
|
|
46
46
|
summary: Loop B
|
|
47
47
|
operationId: loopB
|
|
48
|
-
x-flow:
|
|
48
|
+
x-openapi-flow:
|
|
49
49
|
version: "1.0"
|
|
50
50
|
id: loop-b-flow
|
|
51
51
|
current_state: LOOP_B
|
|
@@ -61,7 +61,7 @@ paths:
|
|
|
61
61
|
post:
|
|
62
62
|
summary: Done
|
|
63
63
|
operationId: doneFlow
|
|
64
|
-
x-flow:
|
|
64
|
+
x-openapi-flow:
|
|
65
65
|
version: "1.0"
|
|
66
66
|
id: done-flow
|
|
67
67
|
current_state: DONE
|
package/examples/order-api.yaml
CHANGED
|
@@ -3,14 +3,14 @@ info:
|
|
|
3
3
|
title: Order API
|
|
4
4
|
version: "1.0.0"
|
|
5
5
|
description: >
|
|
6
|
-
Example API demonstrating x-flow for order lifecycle orchestration.
|
|
6
|
+
Example API demonstrating x-openapi-flow for order lifecycle orchestration.
|
|
7
7
|
|
|
8
8
|
paths:
|
|
9
9
|
/orders:
|
|
10
10
|
post:
|
|
11
11
|
summary: Create a new order
|
|
12
12
|
operationId: createOrder
|
|
13
|
-
x-flow:
|
|
13
|
+
x-openapi-flow:
|
|
14
14
|
version: "1.0"
|
|
15
15
|
id: create-order-flow
|
|
16
16
|
current_state: CREATED
|
|
@@ -19,9 +19,11 @@ paths:
|
|
|
19
19
|
- target_state: CONFIRMED
|
|
20
20
|
condition: Stock and payment checks pass.
|
|
21
21
|
trigger_type: synchronous
|
|
22
|
+
next_operation_id: confirmOrder
|
|
22
23
|
- target_state: CANCELLED
|
|
23
24
|
condition: Validation fails before confirmation.
|
|
24
25
|
trigger_type: synchronous
|
|
26
|
+
next_operation_id: cancelOrder
|
|
25
27
|
responses:
|
|
26
28
|
"201":
|
|
27
29
|
description: Order created successfully.
|
|
@@ -30,7 +32,7 @@ paths:
|
|
|
30
32
|
post:
|
|
31
33
|
summary: Confirm an order
|
|
32
34
|
operationId: confirmOrder
|
|
33
|
-
x-flow:
|
|
35
|
+
x-openapi-flow:
|
|
34
36
|
version: "1.0"
|
|
35
37
|
id: confirm-order-flow
|
|
36
38
|
current_state: CONFIRMED
|
|
@@ -39,6 +41,9 @@ paths:
|
|
|
39
41
|
- target_state: SHIPPED
|
|
40
42
|
condition: Warehouse dispatches package.
|
|
41
43
|
trigger_type: webhook
|
|
44
|
+
next_operation_id: shipOrder
|
|
45
|
+
prerequisite_operation_ids:
|
|
46
|
+
- createOrder
|
|
42
47
|
parameters:
|
|
43
48
|
- name: id
|
|
44
49
|
in: path
|
|
@@ -53,7 +58,7 @@ paths:
|
|
|
53
58
|
post:
|
|
54
59
|
summary: Mark order as shipped
|
|
55
60
|
operationId: shipOrder
|
|
56
|
-
x-flow:
|
|
61
|
+
x-openapi-flow:
|
|
57
62
|
version: "1.0"
|
|
58
63
|
id: ship-order-flow
|
|
59
64
|
current_state: SHIPPED
|
|
@@ -62,6 +67,9 @@ paths:
|
|
|
62
67
|
- target_state: DELIVERED
|
|
63
68
|
condition: Carrier confirms delivery.
|
|
64
69
|
trigger_type: webhook
|
|
70
|
+
next_operation_id: deliverOrder
|
|
71
|
+
prerequisite_operation_ids:
|
|
72
|
+
- confirmOrder
|
|
65
73
|
parameters:
|
|
66
74
|
- name: id
|
|
67
75
|
in: path
|
|
@@ -76,7 +84,7 @@ paths:
|
|
|
76
84
|
post:
|
|
77
85
|
summary: Mark order as delivered
|
|
78
86
|
operationId: deliverOrder
|
|
79
|
-
x-flow:
|
|
87
|
+
x-openapi-flow:
|
|
80
88
|
version: "1.0"
|
|
81
89
|
id: deliver-order-flow
|
|
82
90
|
current_state: DELIVERED
|
|
@@ -95,7 +103,7 @@ paths:
|
|
|
95
103
|
post:
|
|
96
104
|
summary: Cancel an order
|
|
97
105
|
operationId: cancelOrder
|
|
98
|
-
x-flow:
|
|
106
|
+
x-openapi-flow:
|
|
99
107
|
version: "1.0"
|
|
100
108
|
id: cancel-order-flow
|
|
101
109
|
current_state: CANCELLED
|
|
@@ -3,14 +3,14 @@ info:
|
|
|
3
3
|
title: Payment API
|
|
4
4
|
version: "1.0.0"
|
|
5
5
|
description: >
|
|
6
|
-
Simple payment API demonstrating the x-flow extension for OpenAPI Spec.
|
|
6
|
+
Simple payment API demonstrating the x-openapi-flow extension for OpenAPI Spec.
|
|
7
7
|
|
|
8
8
|
paths:
|
|
9
9
|
/payments:
|
|
10
10
|
post:
|
|
11
11
|
summary: Create and authorize a payment
|
|
12
12
|
operationId: createPayment
|
|
13
|
-
x-flow:
|
|
13
|
+
x-openapi-flow:
|
|
14
14
|
version: "1.0"
|
|
15
15
|
id: create-payment-flow
|
|
16
16
|
current_state: AUTHORIZED
|
|
@@ -44,7 +44,7 @@ paths:
|
|
|
44
44
|
post:
|
|
45
45
|
summary: Capture an authorized payment
|
|
46
46
|
operationId: capturePayment
|
|
47
|
-
x-flow:
|
|
47
|
+
x-openapi-flow:
|
|
48
48
|
version: "1.0"
|
|
49
49
|
id: capture-payment-flow
|
|
50
50
|
current_state: CAPTURED
|
|
@@ -10,7 +10,7 @@ paths:
|
|
|
10
10
|
post:
|
|
11
11
|
summary: Start flow A
|
|
12
12
|
operationId: startA
|
|
13
|
-
x-flow:
|
|
13
|
+
x-openapi-flow:
|
|
14
14
|
version: "1.0"
|
|
15
15
|
id: start-a-flow
|
|
16
16
|
current_state: A_START
|
|
@@ -29,7 +29,7 @@ paths:
|
|
|
29
29
|
post:
|
|
30
30
|
summary: Complete flow A
|
|
31
31
|
operationId: doneA
|
|
32
|
-
x-flow:
|
|
32
|
+
x-openapi-flow:
|
|
33
33
|
version: "1.0"
|
|
34
34
|
id: done-a-flow
|
|
35
35
|
current_state: A_DONE
|
|
@@ -41,7 +41,7 @@ paths:
|
|
|
41
41
|
post:
|
|
42
42
|
summary: Start flow B
|
|
43
43
|
operationId: startB
|
|
44
|
-
x-flow:
|
|
44
|
+
x-openapi-flow:
|
|
45
45
|
version: "1.0"
|
|
46
46
|
id: start-b-flow
|
|
47
47
|
current_state: B_START
|
|
@@ -57,7 +57,7 @@ paths:
|
|
|
57
57
|
post:
|
|
58
58
|
summary: Complete flow B
|
|
59
59
|
operationId: doneB
|
|
60
|
-
x-flow:
|
|
60
|
+
x-openapi-flow:
|
|
61
61
|
version: "1.0"
|
|
62
62
|
id: done-b-flow
|
|
63
63
|
current_state: B_DONE
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>x-openapi-flow + Swagger UI</title>
|
|
7
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
|
8
|
+
<style>
|
|
9
|
+
body { margin: 0; }
|
|
10
|
+
#swagger-ui { max-width: 1200px; margin: 0 auto; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<div id="swagger-ui"></div>
|
|
15
|
+
|
|
16
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
|
17
|
+
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
|
|
18
|
+
<script src="./x-openapi-flow-plugin.js"></script>
|
|
19
|
+
<script>
|
|
20
|
+
window.ui = SwaggerUIBundle({
|
|
21
|
+
url: "../payment-api.yaml",
|
|
22
|
+
dom_id: "#swagger-ui",
|
|
23
|
+
deepLinking: true,
|
|
24
|
+
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
|
|
25
|
+
plugins: [window.XOpenApiFlowPlugin],
|
|
26
|
+
showExtensions: true,
|
|
27
|
+
});
|
|
28
|
+
</script>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
window.XOpenApiFlowPlugin = function () {
|
|
2
|
+
return {
|
|
3
|
+
wrapComponents: {
|
|
4
|
+
OperationSummary: (Original, system) => (props) => {
|
|
5
|
+
const operation = props.operation;
|
|
6
|
+
const flow = operation && operation.get && operation.get("x-openapi-flow");
|
|
7
|
+
|
|
8
|
+
if (!flow) {
|
|
9
|
+
return React.createElement(Original, props);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const flowObject = flow && flow.toJS ? flow.toJS() : flow;
|
|
13
|
+
const currentState = flowObject.current_state || "-";
|
|
14
|
+
const version = flowObject.version || "-";
|
|
15
|
+
|
|
16
|
+
return React.createElement(
|
|
17
|
+
"div",
|
|
18
|
+
null,
|
|
19
|
+
React.createElement(Original, props),
|
|
20
|
+
React.createElement(
|
|
21
|
+
"div",
|
|
22
|
+
{
|
|
23
|
+
style: {
|
|
24
|
+
marginTop: "8px",
|
|
25
|
+
padding: "8px 10px",
|
|
26
|
+
border: "1px solid #d9d9d9",
|
|
27
|
+
borderRadius: "6px",
|
|
28
|
+
background: "#fafafa",
|
|
29
|
+
fontSize: "12px",
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
React.createElement("strong", null, "x-openapi-flow"),
|
|
33
|
+
React.createElement(
|
|
34
|
+
"div",
|
|
35
|
+
{ style: { marginTop: "4px" } },
|
|
36
|
+
"version: ",
|
|
37
|
+
version,
|
|
38
|
+
" | current_state: ",
|
|
39
|
+
currentState
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
);
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
};
|
package/examples/ticket-api.yaml
CHANGED
|
@@ -3,14 +3,14 @@ info:
|
|
|
3
3
|
title: Support Ticket API
|
|
4
4
|
version: "1.0.0"
|
|
5
5
|
description: >
|
|
6
|
-
Example API demonstrating x-flow for support ticket lifecycle.
|
|
6
|
+
Example API demonstrating x-openapi-flow for support ticket lifecycle.
|
|
7
7
|
|
|
8
8
|
paths:
|
|
9
9
|
/tickets:
|
|
10
10
|
post:
|
|
11
11
|
summary: Open a support ticket
|
|
12
12
|
operationId: openTicket
|
|
13
|
-
x-flow:
|
|
13
|
+
x-openapi-flow:
|
|
14
14
|
version: "1.0"
|
|
15
15
|
id: open-ticket-flow
|
|
16
16
|
current_state: OPEN
|
|
@@ -27,7 +27,7 @@ paths:
|
|
|
27
27
|
post:
|
|
28
28
|
summary: Start ticket handling
|
|
29
29
|
operationId: startTicket
|
|
30
|
-
x-flow:
|
|
30
|
+
x-openapi-flow:
|
|
31
31
|
version: "1.0"
|
|
32
32
|
id: start-ticket-flow
|
|
33
33
|
current_state: IN_PROGRESS
|
|
@@ -53,7 +53,7 @@ paths:
|
|
|
53
53
|
post:
|
|
54
54
|
summary: Wait for customer response
|
|
55
55
|
operationId: waitCustomer
|
|
56
|
-
x-flow:
|
|
56
|
+
x-openapi-flow:
|
|
57
57
|
version: "1.0"
|
|
58
58
|
id: wait-customer-flow
|
|
59
59
|
current_state: WAITING_CUSTOMER
|
|
@@ -76,7 +76,7 @@ paths:
|
|
|
76
76
|
post:
|
|
77
77
|
summary: Resolve ticket
|
|
78
78
|
operationId: resolveTicket
|
|
79
|
-
x-flow:
|
|
79
|
+
x-openapi-flow:
|
|
80
80
|
version: "1.0"
|
|
81
81
|
id: resolve-ticket-flow
|
|
82
82
|
current_state: RESOLVED
|
|
@@ -99,7 +99,7 @@ paths:
|
|
|
99
99
|
post:
|
|
100
100
|
summary: Close ticket
|
|
101
101
|
operationId: closeTicket
|
|
102
|
-
x-flow:
|
|
102
|
+
x-openapi-flow:
|
|
103
103
|
version: "1.0"
|
|
104
104
|
id: close-ticket-flow
|
|
105
105
|
current_state: CLOSED
|
package/lib/validator.js
CHANGED
|
@@ -38,9 +38,9 @@ function loadApi(filePath) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Extract every x-flow object found in the `paths` section of an OAS document.
|
|
41
|
+
* Extract every x-openapi-flow object found in the `paths` section of an OAS document.
|
|
42
42
|
* @param {object} api - Parsed OAS document.
|
|
43
|
-
* @returns {{ endpoint: string, flow: object }[]}
|
|
43
|
+
* @returns {{ endpoint: string, operation_id?: string, flow: object }[]}
|
|
44
44
|
*/
|
|
45
45
|
function extractFlows(api) {
|
|
46
46
|
const entries = [];
|
|
@@ -59,10 +59,11 @@ function extractFlows(api) {
|
|
|
59
59
|
];
|
|
60
60
|
for (const method of HTTP_METHODS) {
|
|
61
61
|
const operation = pathItem[method];
|
|
62
|
-
if (operation && operation["x-flow"]) {
|
|
62
|
+
if (operation && operation["x-openapi-flow"]) {
|
|
63
63
|
entries.push({
|
|
64
64
|
endpoint: `${method.toUpperCase()} ${pathKey}`,
|
|
65
|
-
|
|
65
|
+
operation_id: operation.operationId,
|
|
66
|
+
flow: operation["x-openapi-flow"],
|
|
66
67
|
});
|
|
67
68
|
}
|
|
68
69
|
}
|
|
@@ -76,7 +77,7 @@ function extractFlows(api) {
|
|
|
76
77
|
// ---------------------------------------------------------------------------
|
|
77
78
|
|
|
78
79
|
/**
|
|
79
|
-
* Validate all x-flow objects against the JSON Schema.
|
|
80
|
+
* Validate all x-openapi-flow objects against the JSON Schema.
|
|
80
81
|
* @param {{ endpoint: string, flow: object }[]} flows
|
|
81
82
|
* @returns {{ endpoint: string, errors: object[] }[]} Array of validation failures.
|
|
82
83
|
*/
|
|
@@ -105,23 +106,23 @@ function suggestFixes(errors = []) {
|
|
|
105
106
|
if (err.keyword === "required" && err.params && err.params.missingProperty) {
|
|
106
107
|
const missing = err.params.missingProperty;
|
|
107
108
|
if (missing === "version") {
|
|
108
|
-
suggestions.add("Add `version: \"1.0\"` to the x-flow object.");
|
|
109
|
+
suggestions.add("Add `version: \"1.0\"` to the x-openapi-flow object.");
|
|
109
110
|
} else if (missing === "id") {
|
|
110
|
-
suggestions.add("Add a unique `id` to the x-flow object.");
|
|
111
|
+
suggestions.add("Add a unique `id` to the x-openapi-flow object.");
|
|
111
112
|
} else if (missing === "current_state") {
|
|
112
113
|
suggestions.add("Add `current_state` to describe the operation state.");
|
|
113
114
|
} else {
|
|
114
|
-
suggestions.add(`Add required property \`${missing}\` in x-flow.`);
|
|
115
|
+
suggestions.add(`Add required property \`${missing}\` in x-openapi-flow.`);
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
if (err.keyword === "enum" && err.instancePath.endsWith("/version")) {
|
|
119
|
-
suggestions.add("Use supported x-flow version: `\"1.0\"`.");
|
|
120
|
+
suggestions.add("Use supported x-openapi-flow version: `\"1.0\"`.");
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
if (err.keyword === "additionalProperties" && err.params && err.params.additionalProperty) {
|
|
123
124
|
suggestions.add(
|
|
124
|
-
`Remove unsupported property \`${err.params.additionalProperty}\` from x-flow payload.`
|
|
125
|
+
`Remove unsupported property \`${err.params.additionalProperty}\` from x-openapi-flow payload.`
|
|
125
126
|
);
|
|
126
127
|
}
|
|
127
128
|
}
|
|
@@ -147,11 +148,55 @@ function defaultResult(pathValue, ok = true) {
|
|
|
147
148
|
multiple_initial_states: [],
|
|
148
149
|
duplicate_transitions: [],
|
|
149
150
|
non_terminating_states: [],
|
|
151
|
+
invalid_operation_references: [],
|
|
150
152
|
warnings: [],
|
|
151
153
|
},
|
|
152
154
|
};
|
|
153
155
|
}
|
|
154
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Detect invalid operationId references declared in transitions.
|
|
159
|
+
* @param {{ endpoint: string, operation_id?: string, flow: object }[]} flows
|
|
160
|
+
* @returns {{ type: string, operation_id: string, declared_in: string }[]}
|
|
161
|
+
*/
|
|
162
|
+
function detectInvalidOperationReferences(flows) {
|
|
163
|
+
const knownOperationIds = new Set(
|
|
164
|
+
flows.map(({ operation_id }) => operation_id).filter(Boolean)
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const invalidReferences = [];
|
|
168
|
+
|
|
169
|
+
for (const { endpoint, flow } of flows) {
|
|
170
|
+
const transitions = flow.transitions || [];
|
|
171
|
+
|
|
172
|
+
for (const transition of transitions) {
|
|
173
|
+
if (transition.next_operation_id && !knownOperationIds.has(transition.next_operation_id)) {
|
|
174
|
+
invalidReferences.push({
|
|
175
|
+
type: "next_operation_id",
|
|
176
|
+
operation_id: transition.next_operation_id,
|
|
177
|
+
declared_in: endpoint,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const prerequisites = Array.isArray(transition.prerequisite_operation_ids)
|
|
182
|
+
? transition.prerequisite_operation_ids
|
|
183
|
+
: [];
|
|
184
|
+
|
|
185
|
+
for (const prerequisiteOperationId of prerequisites) {
|
|
186
|
+
if (!knownOperationIds.has(prerequisiteOperationId)) {
|
|
187
|
+
invalidReferences.push({
|
|
188
|
+
type: "prerequisite_operation_ids",
|
|
189
|
+
operation_id: prerequisiteOperationId,
|
|
190
|
+
declared_in: endpoint,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return invalidReferences;
|
|
198
|
+
}
|
|
199
|
+
|
|
155
200
|
/**
|
|
156
201
|
* Verify that every target_state referenced in transitions corresponds to a
|
|
157
202
|
* current_state defined in at least one endpoint of the same API.
|
|
@@ -474,7 +519,7 @@ function run(apiPath, options = {}) {
|
|
|
474
519
|
return result;
|
|
475
520
|
}
|
|
476
521
|
|
|
477
|
-
// 2. Extract x-flow objects
|
|
522
|
+
// 2. Extract x-openapi-flow objects
|
|
478
523
|
const flows = extractFlows(api);
|
|
479
524
|
|
|
480
525
|
if (flows.length === 0) {
|
|
@@ -484,14 +529,14 @@ function run(apiPath, options = {}) {
|
|
|
484
529
|
if (output === "json") {
|
|
485
530
|
console.log(JSON.stringify(result, null, 2));
|
|
486
531
|
} else {
|
|
487
|
-
console.warn("WARNING: No x-flow extensions found in the API paths.");
|
|
532
|
+
console.warn("WARNING: No x-openapi-flow extensions found in the API paths.");
|
|
488
533
|
}
|
|
489
534
|
|
|
490
535
|
return result;
|
|
491
536
|
}
|
|
492
537
|
|
|
493
538
|
if (output === "pretty") {
|
|
494
|
-
console.log(`Found ${flows.length} x-flow definition(s).\n`);
|
|
539
|
+
console.log(`Found ${flows.length} x-openapi-flow definition(s).\n`);
|
|
495
540
|
}
|
|
496
541
|
|
|
497
542
|
let hasErrors = false;
|
|
@@ -504,7 +549,7 @@ function run(apiPath, options = {}) {
|
|
|
504
549
|
|
|
505
550
|
if (output === "pretty") {
|
|
506
551
|
if (schemaFailures.length === 0) {
|
|
507
|
-
console.log("✔ Schema validation passed for all x-flow definitions.");
|
|
552
|
+
console.log("✔ Schema validation passed for all x-openapi-flow definitions.");
|
|
508
553
|
} else {
|
|
509
554
|
console.error("✘ Schema validation FAILED:");
|
|
510
555
|
for (const { endpoint, errors } of schemaFailures) {
|
|
@@ -554,6 +599,7 @@ function run(apiPath, options = {}) {
|
|
|
554
599
|
const cycle = detectCycle(graph);
|
|
555
600
|
const duplicateTransitions = detectDuplicateTransitions(flows);
|
|
556
601
|
const terminalCoverage = detectTerminalCoverage(graph);
|
|
602
|
+
const invalidOperationReferences = detectInvalidOperationReferences(flows);
|
|
557
603
|
const multipleInitialStates = initialStates.length > 1 ? initialStates : [];
|
|
558
604
|
|
|
559
605
|
if (profileConfig.runAdvanced) {
|
|
@@ -590,6 +636,15 @@ function run(apiPath, options = {}) {
|
|
|
590
636
|
);
|
|
591
637
|
}
|
|
592
638
|
|
|
639
|
+
if (profileConfig.runQuality && invalidOperationReferences.length > 0) {
|
|
640
|
+
const invalidOperationIds = [
|
|
641
|
+
...new Set(invalidOperationReferences.map((item) => item.operation_id)),
|
|
642
|
+
];
|
|
643
|
+
qualityWarnings.push(
|
|
644
|
+
`Transition operation references not found: ${invalidOperationIds.join(", ")}`
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
|
|
593
648
|
if (strictQuality && qualityWarnings.length > 0) {
|
|
594
649
|
hasErrors = true;
|
|
595
650
|
}
|
|
@@ -678,6 +733,7 @@ function run(apiPath, options = {}) {
|
|
|
678
733
|
multiple_initial_states: multipleInitialStates,
|
|
679
734
|
duplicate_transitions: duplicateTransitions,
|
|
680
735
|
non_terminating_states: terminalCoverage.non_terminating_states,
|
|
736
|
+
invalid_operation_references: invalidOperationReferences,
|
|
681
737
|
warnings: qualityWarnings,
|
|
682
738
|
},
|
|
683
739
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "x-openapi-flow",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "OpenAPI extension for resource workflow and lifecycle management",
|
|
5
5
|
"main": "lib/validator.js",
|
|
6
6
|
"repository": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"openapi-flow",
|
|
40
40
|
"workflow",
|
|
41
41
|
"lifecycle",
|
|
42
|
-
"x-flow"
|
|
42
|
+
"x-openapi-flow"
|
|
43
43
|
],
|
|
44
44
|
"license": "MIT",
|
|
45
45
|
"dependencies": {
|
package/schema/flow-schema.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"$id": "https://x-flow.dev/schema/flow-schema.json",
|
|
4
|
-
"title": "x-flow",
|
|
5
|
-
"description": "JSON Schema for the x-flow OpenAPI extension, defining resource lifecycle states and transitions.",
|
|
3
|
+
"$id": "https://x-openapi-flow.dev/schema/flow-schema.json",
|
|
4
|
+
"title": "x-openapi-flow",
|
|
5
|
+
"description": "JSON Schema for the x-openapi-flow OpenAPI extension, defining resource lifecycle states and transitions.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"required": ["version", "id", "current_state"],
|
|
8
8
|
"properties": {
|
|
9
9
|
"version": {
|
|
10
10
|
"type": "string",
|
|
11
11
|
"enum": ["1.0"],
|
|
12
|
-
"description": "Version of the x-flow schema contract used by this definition."
|
|
12
|
+
"description": "Version of the x-openapi-flow schema contract used by this definition."
|
|
13
13
|
},
|
|
14
14
|
"id": {
|
|
15
15
|
"type": "string",
|
|
@@ -58,6 +58,17 @@
|
|
|
58
58
|
"type": "string",
|
|
59
59
|
"enum": ["synchronous", "webhook", "polling"],
|
|
60
60
|
"description": "Mechanism that triggers the state transition."
|
|
61
|
+
},
|
|
62
|
+
"next_operation_id": {
|
|
63
|
+
"type": "string",
|
|
64
|
+
"description": "operationId usually called to apply the next state transition."
|
|
65
|
+
},
|
|
66
|
+
"prerequisite_operation_ids": {
|
|
67
|
+
"type": "array",
|
|
68
|
+
"description": "operationIds that are expected to have happened before this transition.",
|
|
69
|
+
"items": {
|
|
70
|
+
"type": "string"
|
|
71
|
+
}
|
|
61
72
|
}
|
|
62
73
|
},
|
|
63
74
|
"additionalProperties": false
|