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 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 [output-file] [--title "My API"]
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
+ ![Guided graph example](../docs/assets/graph-order-guided.svg)
44
75
 
45
76
  ## Repository and Full Documentation
46
77
 
@@ -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 [output-file] [--title "My API"]
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 my-api.yaml --title "Orders API"
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, ["--title"], []);
171
+ const unknown = findUnknownOptions(args, ["--flows"], []);
167
172
  if (unknown) {
168
173
  return { error: `Unknown option: ${unknown}` };
169
174
  }
170
175
 
171
- const titleOpt = getOptionValue(args, "--title");
172
- if (titleOpt.error) {
173
- return { error: titleOpt.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 === "--title") {
182
+ if (token === "--flows") {
178
183
  return false;
179
184
  }
180
- if (index > 0 && args[index - 1] === "--title") {
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
- outputFile: path.resolve(positional[0] || "x-flow-api.yaml"),
192
- title: titleOpt.found ? titleOpt.value : "Sample API",
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 buildTemplate(title) {
286
- return `openapi: "3.0.3"
287
- info:
288
- title: ${title}
289
- version: "1.0.0"
290
- paths:
291
- /resources:
292
- post:
293
- summary: Create resource
294
- operationId: createResource
295
- x-flow:
296
- version: "1.0"
297
- id: create-resource-flow
298
- current_state: CREATED
299
- transitions:
300
- - target_state: COMPLETED
301
- condition: Resource reaches terminal condition.
302
- trigger_type: synchronous
303
- responses:
304
- "201":
305
- description: Resource created.
306
-
307
- /resources/{id}/complete:
308
- post:
309
- summary: Complete resource
310
- operationId: completeResource
311
- x-flow:
312
- version: "1.0"
313
- id: complete-resource-flow
314
- current_state: COMPLETED
315
- responses:
316
- "200":
317
- description: Resource completed.
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
- if (fs.existsSync(parsed.outputFile)) {
323
- console.error(`ERROR: File already exists: ${parsed.outputFile}`);
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
- fs.writeFileSync(parsed.outputFile, buildTemplate(parsed.title), "utf8");
328
- console.log(`Template created: ${parsed.outputFile}`);
329
- console.log(`Next step: x-openapi-flow validate ${parsed.outputFile}`);
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
- for (const state of graph.nodes) {
382
- lines.push(` state ${state}`);
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 [from, targets] of graph.adjacency.entries()) {
386
- for (const to of targets) {
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: [...graph.nodes],
394
- edges: [...graph.adjacency.entries()].flatMap(([from, targets]) =>
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
@@ -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
+ };
@@ -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
- flow: operation["x-flow"],
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.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": {
@@ -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