x-openapi-flow 1.1.3 → 1.2.1

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,16 @@
1
1
  # x-openapi-flow
2
2
 
3
- CLI and specification for validating the `x-openapi-flow` extension field in OpenAPI documents.
3
+ CLI and extension contract for documenting and validating resource lifecycle workflows in OpenAPI using `x-openapi-flow`.
4
+
5
+ ## Overview
6
+
7
+ `x-openapi-flow` validates:
8
+
9
+ - Extension schema correctness
10
+ - Lifecycle graph consistency
11
+ - Optional quality checks for transitions and references
12
+
13
+ It also supports a sidecar workflow (`init` + `apply`) to preserve lifecycle metadata when OpenAPI files are regenerated.
4
14
 
5
15
  ## Installation
6
16
 
@@ -8,7 +18,22 @@ CLI and specification for validating the `x-openapi-flow` extension field in Ope
8
18
  npm install x-openapi-flow
9
19
  ```
10
20
 
11
- ## Quick Usage
21
+ Optional mirror on GitHub Packages (default usage remains unscoped on npm):
22
+
23
+ ```bash
24
+ npm config set @tiago-marques:registry https://npm.pkg.github.com
25
+ npm install @tiago-marques/x-openapi-flow
26
+ ```
27
+
28
+ If authentication is required, include this in your `.npmrc`:
29
+
30
+ ```ini
31
+ //npm.pkg.github.com/:_authToken=${GH_PACKAGES_TOKEN}
32
+ ```
33
+
34
+ Use a GitHub PAT with `read:packages` (install) and `write:packages` (publish).
35
+
36
+ ## Quick Start
12
37
 
13
38
  ```bash
14
39
  x-openapi-flow validate openapi.yaml
@@ -16,7 +41,7 @@ x-openapi-flow graph openapi.yaml
16
41
  x-openapi-flow doctor
17
42
  ```
18
43
 
19
- ## Commands
44
+ ## CLI Commands
20
45
 
21
46
  ```bash
22
47
  x-openapi-flow validate <openapi-file> [--format pretty|json] [--profile core|relaxed|strict] [--strict-quality] [--config path]
@@ -26,20 +51,22 @@ x-openapi-flow graph <openapi-file> [--format mermaid|json]
26
51
  x-openapi-flow doctor [--config path]
27
52
  ```
28
53
 
54
+ ## Sidecar Workflow
55
+
29
56
  `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.
57
+ `init` creates/synchronizes `{context}-openapi-flow.(json|yaml)` as a persistent sidecar for your `x-openapi-flow` data.
31
58
  Use `apply` to inject sidecar flows back into regenerated OpenAPI files.
32
59
  If no OpenAPI/Swagger file exists yet, generate one first with your framework's official OpenAPI/Swagger tooling.
33
60
 
34
- ## Recommended Workflow
61
+ ### Recommended Sequence
35
62
 
36
63
  ```bash
37
64
  x-openapi-flow init openapi.yaml
38
- # edit x-openapi-flow.flows.yaml
65
+ # edit {context}-openapi-flow.(json|yaml)
39
66
  x-openapi-flow apply openapi.yaml
40
67
  ```
41
68
 
42
- ## Optional Configuration
69
+ ## Configuration
43
70
 
44
71
  Create `x-openapi-flow.config.json` in your project directory:
45
72
 
@@ -51,29 +78,42 @@ Create `x-openapi-flow.config.json` in your project directory:
51
78
  }
52
79
  ```
53
80
 
54
- ## File Compatibility
81
+ ## Compatibility
55
82
 
56
83
  - OpenAPI input in `.yaml`, `.yml`, and `.json`
57
84
  - Validation processes OAS content with the `x-openapi-flow` extension
58
85
 
59
- ### Optional Transition Guidance Fields
86
+ ## Transition Guidance Fields
60
87
 
61
88
  - `next_operation_id`: operationId usually called for the next state transition
62
89
  - `prerequisite_operation_ids`: operationIds expected before a transition
90
+ - `prerequisite_field_refs`: required field refs before transition
91
+ - `propagated_field_refs`: field refs used by downstream flows
63
92
 
64
- ## Swagger UI
93
+ Field reference format:
94
+
95
+ - `operationId:request.body.field`
96
+ - `operationId:response.<status>.body.field`
97
+
98
+ ## Visualization
99
+
100
+ ### Swagger UI
65
101
 
66
102
  - There is no Swagger UI-based automated test in this repo today (tests are CLI-only).
67
103
  - For UI interpretation of `x-openapi-flow`, use `showExtensions: true` plus the example plugin at `examples/swagger-ui/x-openapi-flow-plugin.js`.
68
104
  - A ready HTML example is available at `examples/swagger-ui/index.html`.
105
+ - The plugin renders a global **Flow Overview** (Mermaid image) near the top of the docs, plus operation-level flow cards.
106
+
107
+ ![Swagger UI integration result](../docs/assets/swagger-ui-integration-result-v2.svg)
69
108
 
70
- ## Graph Output Example
109
+ ### Graph Output Example
71
110
 
72
111
  `x-openapi-flow graph` includes transition guidance labels in Mermaid output when present (`next_operation_id`, `prerequisite_operation_ids`).
112
+ The `graph` command accepts both full OpenAPI files and sidecar files (`{context}-openapi-flow.(json|yaml)`).
73
113
 
74
114
  ![Guided graph example](../docs/assets/graph-order-guided.svg)
75
115
 
76
- ## Repository and Full Documentation
116
+ ## Repository and Documentation
77
117
 
78
118
  - Repository: https://github.com/tiago-marques/x-openapi-flow
79
119
  - Full guide and changelog are available in the root repository.
@@ -53,7 +53,7 @@ Examples:
53
53
  x-openapi-flow validate examples/order-api.yaml
54
54
  x-openapi-flow validate examples/order-api.yaml --profile relaxed
55
55
  x-openapi-flow validate examples/order-api.yaml --strict-quality
56
- x-openapi-flow init openapi.yaml --flows x-openapi-flow.flows.yaml
56
+ x-openapi-flow init openapi.yaml --flows openapi-openapi-flow.yaml
57
57
  x-openapi-flow init
58
58
  x-openapi-flow apply openapi.yaml
59
59
  x-openapi-flow apply openapi.yaml --out openapi.flow.yaml
@@ -386,7 +386,10 @@ function resolveFlowsPath(openApiFile, customFlowsPath) {
386
386
  }
387
387
 
388
388
  if (openApiFile) {
389
- return path.join(path.dirname(openApiFile), DEFAULT_FLOWS_FILE);
389
+ const parsed = path.parse(openApiFile);
390
+ const extension = parsed.ext.toLowerCase() === ".json" ? ".json" : ".yaml";
391
+ const fileName = `${parsed.name}-openapi-flow${extension}`;
392
+ return path.join(path.dirname(openApiFile), fileName);
390
393
  }
391
394
 
392
395
  return path.resolve(process.cwd(), DEFAULT_FLOWS_FILE);
@@ -406,6 +409,15 @@ function saveOpenApi(filePath, api) {
406
409
  fs.writeFileSync(filePath, content, "utf8");
407
410
  }
408
411
 
412
+ function buildFallbackOperationId(method, pathKey) {
413
+ const raw = `${method}_${pathKey}`.toLowerCase();
414
+ const sanitized = raw
415
+ .replace(/[^a-z0-9]+/g, "_")
416
+ .replace(/^_+|_+$/g, "");
417
+
418
+ return sanitized || "operation";
419
+ }
420
+
409
421
  function extractOperationEntries(api) {
410
422
  const entries = [];
411
423
  const paths = (api && api.paths) || {};
@@ -419,13 +431,16 @@ function extractOperationEntries(api) {
419
431
  }
420
432
 
421
433
  const operationId = operation.operationId;
434
+ const resolvedOperationId = operationId || buildFallbackOperationId(method, pathKey);
422
435
  const key = operationId ? `operationId:${operationId}` : `${method.toUpperCase()} ${pathKey}`;
423
436
 
424
437
  entries.push({
425
438
  key,
426
439
  operationId,
440
+ resolvedOperationId,
427
441
  method,
428
442
  path: pathKey,
443
+ operation,
429
444
  });
430
445
  }
431
446
  }
@@ -456,10 +471,22 @@ function readFlowsFile(flowsPath) {
456
471
 
457
472
  function writeFlowsFile(flowsPath, flowsDoc) {
458
473
  fs.mkdirSync(path.dirname(flowsPath), { recursive: true });
459
- const content = yaml.dump(flowsDoc, { noRefs: true, lineWidth: -1 });
474
+ const content = flowsPath.endsWith(".json")
475
+ ? `${JSON.stringify(flowsDoc, null, 2)}\n`
476
+ : yaml.dump(flowsDoc, { noRefs: true, lineWidth: -1 });
460
477
  fs.writeFileSync(flowsPath, content, "utf8");
461
478
  }
462
479
 
480
+ function buildFlowTemplate(operationId) {
481
+ const safeOperationId = operationId || "operation";
482
+ return {
483
+ version: "1.0",
484
+ id: `${safeOperationId}_FLOW_ID`,
485
+ current_state: `${safeOperationId}_STATE`,
486
+ transitions: [],
487
+ };
488
+ }
489
+
463
490
  function buildOperationLookup(api) {
464
491
  const lookupByKey = new Map();
465
492
  const lookupByOperationId = new Map();
@@ -467,8 +494,8 @@ function buildOperationLookup(api) {
467
494
 
468
495
  for (const entry of entries) {
469
496
  lookupByKey.set(entry.key, entry);
470
- if (entry.operationId) {
471
- lookupByOperationId.set(entry.operationId, entry);
497
+ if (entry.resolvedOperationId) {
498
+ lookupByOperationId.set(entry.resolvedOperationId, entry);
472
499
  }
473
500
  }
474
501
 
@@ -476,51 +503,41 @@ function buildOperationLookup(api) {
476
503
  }
477
504
 
478
505
  function mergeFlowsWithOpenApi(api, flowsDoc) {
479
- const { entries, lookupByKey, lookupByOperationId } = buildOperationLookup(api);
506
+ const { entries, lookupByKey } = buildOperationLookup(api);
480
507
 
508
+ const existingByOperationId = new Map();
481
509
  const existingByKey = new Map();
482
510
  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);
511
+ if (entry && entry.operationId) {
512
+ existingByOperationId.set(entry.operationId, entry);
513
+ }
514
+
515
+ const legacyKey = entry && entry.key ? entry.key : null;
516
+ if (legacyKey) {
517
+ existingByKey.set(legacyKey, entry);
486
518
  }
487
519
  }
488
520
 
489
521
  const mergedOperations = [];
490
522
 
491
523
  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
- }
524
+ const existing = (op.resolvedOperationId && existingByOperationId.get(op.resolvedOperationId))
525
+ || existingByKey.get(op.key);
513
526
 
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);
527
+ const openApiFlow =
528
+ op.operation && typeof op.operation["x-openapi-flow"] === "object"
529
+ ? op.operation["x-openapi-flow"]
530
+ : null;
517
531
 
518
- if (!found) {
519
- mergedOperations.push({
520
- ...existing,
521
- missing_in_openapi: true,
522
- });
523
- }
532
+ const sidecarFlow =
533
+ existing && typeof existing["x-openapi-flow"] === "object"
534
+ ? existing["x-openapi-flow"]
535
+ : null;
536
+
537
+ mergedOperations.push({
538
+ operationId: op.resolvedOperationId,
539
+ "x-openapi-flow": sidecarFlow || openApiFlow || buildFlowTemplate(op.resolvedOperationId),
540
+ });
524
541
  }
525
542
 
526
543
  return {
@@ -535,12 +552,12 @@ function applyFlowsToOpenApi(api, flowsDoc) {
535
552
  const { lookupByKey, lookupByOperationId } = buildOperationLookup(api);
536
553
 
537
554
  for (const flowEntry of flowsDoc.operations || []) {
538
- if (!flowEntry || flowEntry.missing_in_openapi === true) {
555
+ if (!flowEntry) {
539
556
  continue;
540
557
  }
541
558
 
542
559
  const flowValue = flowEntry["x-openapi-flow"];
543
- if (!flowValue || typeof flowValue !== "object") {
560
+ if (!flowValue || typeof flowValue !== "object" || Object.keys(flowValue).length === 0) {
544
561
  continue;
545
562
  }
546
563
 
@@ -595,19 +612,12 @@ function runInit(parsed) {
595
612
 
596
613
  const mergedFlows = mergeFlowsWithOpenApi(api, flowsDoc);
597
614
  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;
615
+ const trackedCount = mergedFlows.operations.length;
603
616
 
604
617
  console.log(`Using existing OpenAPI file: ${targetOpenApiFile}`);
605
618
  console.log(`Flows sidecar synced: ${flowsPath}`);
606
619
  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}`);
620
+ console.log("OpenAPI source unchanged. Edit the sidecar and run apply to generate the full spec.");
611
621
 
612
622
  console.log(`Validate now: x-openapi-flow validate ${targetOpenApiFile}`);
613
623
  return 0;
@@ -699,8 +709,11 @@ function runDoctor(parsed) {
699
709
  }
700
710
 
701
711
  function buildMermaidGraph(filePath) {
702
- const api = loadApi(filePath);
703
- const flows = extractFlows(api);
712
+ const flows = extractFlowsForGraph(filePath);
713
+ if (flows.length === 0) {
714
+ throw new Error("No x-openapi-flow definitions found in OpenAPI or sidecar file");
715
+ }
716
+
704
717
  const lines = ["stateDiagram-v2"];
705
718
  const nodes = new Set();
706
719
  const edges = [];
@@ -760,6 +773,51 @@ function buildMermaidGraph(filePath) {
760
773
  };
761
774
  }
762
775
 
776
+ function extractFlowsForGraph(filePath) {
777
+ let flows = [];
778
+
779
+ try {
780
+ const api = loadApi(filePath);
781
+ flows = extractFlows(api);
782
+ } catch (_err) {
783
+ flows = [];
784
+ }
785
+
786
+ if (flows.length > 0) {
787
+ return flows;
788
+ }
789
+
790
+ const content = fs.readFileSync(filePath, "utf8");
791
+ const parsed = yaml.load(content);
792
+
793
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.operations)) {
794
+ return [];
795
+ }
796
+
797
+ const sidecarFlows = [];
798
+ for (const operationEntry of parsed.operations) {
799
+ if (!operationEntry || typeof operationEntry !== "object") {
800
+ continue;
801
+ }
802
+
803
+ const flow = operationEntry["x-openapi-flow"];
804
+ if (!flow || typeof flow !== "object") {
805
+ continue;
806
+ }
807
+
808
+ if (!flow.current_state) {
809
+ continue;
810
+ }
811
+
812
+ sidecarFlows.push({
813
+ endpoint: operationEntry.operationId || operationEntry.key || "sidecar-operation",
814
+ flow,
815
+ });
816
+ }
817
+
818
+ return sidecarFlows;
819
+ }
820
+
763
821
  function runGraph(parsed) {
764
822
  try {
765
823
  const graphResult = buildMermaidGraph(parsed.filePath);
@@ -22,6 +22,12 @@ paths:
22
22
  - target_state: CAPTURED
23
23
  condition: Merchant explicitly requests capture.
24
24
  trigger_type: synchronous
25
+ next_operation_id: capturePayment
26
+ prerequisite_field_refs:
27
+ - createPayment:response.201.body.id
28
+ propagated_field_refs:
29
+ - createPayment:request.body.amount
30
+ - createPayment:request.body.currency
25
31
  requestBody:
26
32
  required: true
27
33
  content:
@@ -15,6 +15,9 @@
15
15
 
16
16
  <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
17
17
  <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
18
+ <script>
19
+ window.XOpenApiFlowGraphImageUrl = "https://raw.githubusercontent.com/tiago-marques/x-openapi-flow/main/docs/assets/graph-order-guided.svg";
20
+ </script>
18
21
  <script src="./x-openapi-flow-plugin.js"></script>
19
22
  <script>
20
23
  window.ui = SwaggerUIBundle({
@@ -1,46 +1,427 @@
1
1
  window.XOpenApiFlowPlugin = function () {
2
+ const h = React.createElement;
3
+
4
+ function toPlain(value) {
5
+ if (!value) return value;
6
+ return value.toJS ? value.toJS() : value;
7
+ }
8
+
9
+ function text(value) {
10
+ if (value === null || value === undefined || value === "") return "-";
11
+ if (Array.isArray(value)) return value.length ? value.join(", ") : "-";
12
+ return String(value);
13
+ }
14
+
15
+ function transitionsList(currentState, transitions) {
16
+ if (!Array.isArray(transitions) || transitions.length === 0) {
17
+ return h("div", { style: { opacity: 0.85, fontStyle: "italic" } }, "No transitions (terminal state)");
18
+ }
19
+
20
+ return h(
21
+ "ul",
22
+ { style: { margin: "6px 0 0 18px", padding: 0 } },
23
+ transitions.map((transition, index) =>
24
+ h(
25
+ "li",
26
+ { key: `${currentState}-${index}`, style: { marginBottom: "4px", lineHeight: 1.45 } },
27
+ h("strong", null, text(transition.trigger_type)),
28
+ " → ",
29
+ h("strong", null, text(transition.target_state)),
30
+ transition.condition ? ` — ${text(transition.condition)}` : "",
31
+ transition.next_operation_id ? ` (next: ${text(transition.next_operation_id)})` : ""
32
+ )
33
+ )
34
+ );
35
+ }
36
+
37
+ function miniGraph(currentState, transitions) {
38
+ if (!Array.isArray(transitions) || transitions.length === 0) {
39
+ return [h("div", { key: "terminal", style: { fontFamily: "monospace" } }, `${text(currentState)} [terminal]`)];
40
+ }
41
+
42
+ return transitions.map((transition, index) =>
43
+ h(
44
+ "div",
45
+ { key: `edge-${index}`, style: { fontFamily: "monospace", lineHeight: 1.45 } },
46
+ `${text(currentState)} --> ${text(transition.target_state)} [${text(transition.trigger_type)}]`
47
+ )
48
+ );
49
+ }
50
+
2
51
  return {
3
52
  wrapComponents: {
4
- OperationSummary: (Original, system) => (props) => {
53
+ OperationSummary: (Original) => (props) => {
5
54
  const operation = props.operation;
6
55
  const flow = operation && operation.get && operation.get("x-openapi-flow");
7
56
 
8
57
  if (!flow) {
9
- return React.createElement(Original, props);
58
+ return h(Original, props);
10
59
  }
11
60
 
12
- const flowObject = flow && flow.toJS ? flow.toJS() : flow;
13
- const currentState = flowObject.current_state || "-";
14
- const version = flowObject.version || "-";
61
+ const flowObject = toPlain(flow) || {};
62
+ const currentState = flowObject.current_state;
63
+ const transitions = Array.isArray(flowObject.transitions) ? flowObject.transitions : [];
64
+ const graphImageUrl = flowObject.graph_image_url || window.XOpenApiFlowGraphImageUrl;
65
+
66
+ const metadataGrid = h(
67
+ "div",
68
+ {
69
+ style: {
70
+ display: "grid",
71
+ gridTemplateColumns: "140px 1fr",
72
+ gap: "4px 10px",
73
+ fontSize: "12px",
74
+ marginTop: "6px",
75
+ },
76
+ },
77
+ h("div", { style: { opacity: 0.85 } }, "version"),
78
+ h("div", null, text(flowObject.version)),
79
+ h("div", { style: { opacity: 0.85 } }, "id"),
80
+ h("div", null, text(flowObject.id)),
81
+ h("div", { style: { opacity: 0.85 } }, "current_state"),
82
+ h("div", null, text(currentState))
83
+ );
84
+
85
+ const graphImageNode = graphImageUrl
86
+ ? h(
87
+ "div",
88
+ { style: { marginTop: "10px" } },
89
+ h("div", { style: { fontWeight: 700, marginBottom: "6px" } }, "Flow graph image"),
90
+ h("img", {
91
+ src: graphImageUrl,
92
+ alt: "x-openapi-flow graph",
93
+ style: {
94
+ width: "100%",
95
+ maxWidth: "560px",
96
+ border: "1px solid rgba(255,255,255,0.3)",
97
+ borderRadius: "6px",
98
+ },
99
+ })
100
+ )
101
+ : null;
15
102
 
16
- return React.createElement(
103
+ return h(
17
104
  "div",
18
105
  null,
19
- React.createElement(Original, props),
20
- React.createElement(
106
+ h(Original, props),
107
+ h(
21
108
  "div",
22
109
  {
23
110
  style: {
24
111
  marginTop: "8px",
25
- padding: "8px 10px",
26
- border: "1px solid #d9d9d9",
27
- borderRadius: "6px",
28
- background: "#fafafa",
112
+ padding: "10px",
113
+ border: "1px solid rgba(255,255,255,0.28)",
114
+ borderRadius: "8px",
115
+ background: "rgba(0,0,0,0.12)",
29
116
  fontSize: "12px",
30
117
  },
31
118
  },
32
- React.createElement("strong", null, "x-openapi-flow"),
33
- React.createElement(
119
+ h("div", { style: { fontWeight: 700 } }, "x-openapi-flow"),
120
+ metadataGrid,
121
+ h("div", { style: { marginTop: "10px", fontWeight: 700 } }, "Transitions"),
122
+ transitionsList(currentState, transitions),
123
+ h("div", { style: { marginTop: "10px", fontWeight: 700 } }, "Flow graph (operation-level)"),
124
+ h(
34
125
  "div",
35
- { style: { marginTop: "4px" } },
36
- "version: ",
37
- version,
38
- " | current_state: ",
39
- currentState
40
- )
126
+ {
127
+ style: {
128
+ marginTop: "6px",
129
+ border: "1px dashed rgba(255,255,255,0.32)",
130
+ borderRadius: "6px",
131
+ padding: "8px",
132
+ },
133
+ },
134
+ ...miniGraph(currentState, transitions)
135
+ ),
136
+ graphImageNode
41
137
  )
42
138
  );
43
139
  },
44
140
  },
45
141
  };
46
142
  };
143
+
144
+ (function () {
145
+ const styleId = 'x-openapi-flow-ui-style';
146
+
147
+ function injectStyles() {
148
+ if (document.getElementById(styleId)) return;
149
+
150
+ const style = document.createElement('style');
151
+ style.id = styleId;
152
+ style.textContent = `
153
+ .xof-card { border: 1px solid rgba(255,255,255,0.28); border-radius: 8px; padding: 10px; background: rgba(0,0,0,0.12); }
154
+ .xof-title { font-weight: 700; margin-bottom: 8px; }
155
+ .xof-meta { display: grid; grid-template-columns: 140px 1fr; gap: 4px 10px; font-size: 12px; margin-bottom: 10px; }
156
+ .xof-meta-label { opacity: 0.85; }
157
+ .xof-list { margin: 0; padding-left: 18px; }
158
+ .xof-list li { margin: 4px 0; }
159
+ .xof-graph { margin-top: 10px; padding: 8px; border: 1px dashed rgba(255,255,255,0.32); border-radius: 6px; }
160
+ .xof-graph-title { font-size: 12px; font-weight: 700; margin-bottom: 6px; }
161
+ .xof-edge { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; font-size: 12px; line-height: 1.45; white-space: pre-wrap; }
162
+ .xof-empty { opacity: 0.85; font-style: italic; }
163
+ .xof-overview { margin: 10px 0 16px; }
164
+ .xof-overview img { width: 100%; max-width: 760px; border: 1px solid rgba(255,255,255,0.3); border-radius: 6px; background: #fff; }
165
+ .xof-overview-code { margin-top: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace; font-size: 11px; opacity: 0.9; white-space: pre-wrap; }
166
+ `;
167
+
168
+ document.head.appendChild(style);
169
+ }
170
+
171
+ function text(value) {
172
+ if (value === null || value === undefined || value === '') return '-';
173
+ if (Array.isArray(value)) return value.length ? value.join(', ') : '-';
174
+ return String(value);
175
+ }
176
+
177
+ function renderTransitions(currentState, transitions) {
178
+ if (!Array.isArray(transitions) || transitions.length === 0) {
179
+ return '<div class="xof-empty">No transitions (terminal state)</div>';
180
+ }
181
+
182
+ return `<ul class="xof-list">${transitions
183
+ .map((transition) => {
184
+ const condition = transition.condition ? ` — ${text(transition.condition)}` : '';
185
+ const nextOperation = transition.next_operation_id ? ` (next: ${text(transition.next_operation_id)})` : '';
186
+ return `<li><strong>${text(transition.trigger_type)}</strong> → <strong>${text(transition.target_state)}</strong>${condition}${nextOperation}</li>`;
187
+ })
188
+ .join('')}</ul>`;
189
+ }
190
+
191
+ function renderGraph(currentState, transitions) {
192
+ if (!Array.isArray(transitions) || transitions.length === 0) {
193
+ return `<div class="xof-edge">${text(currentState)} [terminal]</div>`;
194
+ }
195
+
196
+ return transitions
197
+ .map((transition) => `<div class="xof-edge">${text(currentState)} --> ${text(transition.target_state)} [${text(transition.trigger_type)}]</div>`)
198
+ .join('');
199
+ }
200
+
201
+ function renderCard(flow) {
202
+ const transitions = Array.isArray(flow.transitions) ? flow.transitions : [];
203
+ return `
204
+ <div class="xof-card">
205
+ <div class="xof-title">x-openapi-flow</div>
206
+ <div class="xof-meta">
207
+ <div class="xof-meta-label">version</div><div>${text(flow.version)}</div>
208
+ <div class="xof-meta-label">id</div><div>${text(flow.id)}</div>
209
+ <div class="xof-meta-label">current_state</div><div>${text(flow.current_state)}</div>
210
+ </div>
211
+ <div><strong>Transitions</strong></div>
212
+ ${renderTransitions(flow.current_state, transitions)}
213
+ <div class="xof-graph">
214
+ <div class="xof-graph-title">Flow graph (operation-level)</div>
215
+ ${renderGraph(flow.current_state, transitions)}
216
+ </div>
217
+ </div>
218
+ `;
219
+ }
220
+
221
+ function getSpecFromUi() {
222
+ try {
223
+ if (!window.ui || !window.ui.specSelectors || !window.ui.specSelectors.specJson) {
224
+ return null;
225
+ }
226
+
227
+ const spec = window.ui.specSelectors.specJson();
228
+ return spec && spec.toJS ? spec.toJS() : spec;
229
+ } catch (_error) {
230
+ return null;
231
+ }
232
+ }
233
+
234
+ function extractFlowsFromSpec(spec) {
235
+ const result = [];
236
+ const paths = (spec && spec.paths) || {};
237
+ const methods = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
238
+
239
+ Object.entries(paths).forEach(([pathKey, pathItem]) => {
240
+ if (!pathItem || typeof pathItem !== 'object') return;
241
+
242
+ methods.forEach((method) => {
243
+ const operation = pathItem[method];
244
+ if (!operation || typeof operation !== 'object') return;
245
+
246
+ const flow = operation['x-openapi-flow'];
247
+ if (!flow || typeof flow !== 'object' || !flow.current_state) return;
248
+
249
+ result.push({
250
+ operationId: operation.operationId || `${method}_${pathKey}`,
251
+ flow,
252
+ });
253
+ });
254
+ });
255
+
256
+ return result;
257
+ }
258
+
259
+ function buildOverviewMermaid(flows) {
260
+ const lines = ['stateDiagram-v2'];
261
+ const states = new Set();
262
+ const seen = new Set();
263
+
264
+ flows.forEach(({ flow }) => {
265
+ const current = flow.current_state;
266
+ if (!current) return;
267
+
268
+ states.add(current);
269
+ const transitions = Array.isArray(flow.transitions) ? flow.transitions : [];
270
+ transitions.forEach((transition) => {
271
+ const target = transition.target_state;
272
+ if (!target) return;
273
+ states.add(target);
274
+
275
+ const labelParts = [];
276
+ if (transition.next_operation_id) {
277
+ labelParts.push(`next:${text(transition.next_operation_id)}`);
278
+ }
279
+ if (Array.isArray(transition.prerequisite_operation_ids) && transition.prerequisite_operation_ids.length) {
280
+ labelParts.push(`requires:${transition.prerequisite_operation_ids.join(',')}`);
281
+ }
282
+ const label = labelParts.join(' | ');
283
+ const key = `${current}::${target}::${label}`;
284
+ if (seen.has(key)) return;
285
+ seen.add(key);
286
+ lines.push(` ${current} --> ${target}${label ? `: ${label}` : ''}`);
287
+ });
288
+ });
289
+
290
+ Array.from(states)
291
+ .sort()
292
+ .forEach((state) => {
293
+ lines.splice(1, 0, ` state ${state}`);
294
+ });
295
+
296
+ return lines.join('\n');
297
+ }
298
+
299
+ let mermaidLoaderPromise = null;
300
+ function ensureMermaid() {
301
+ if (window.mermaid) {
302
+ return Promise.resolve(window.mermaid);
303
+ }
304
+
305
+ if (mermaidLoaderPromise) {
306
+ return mermaidLoaderPromise;
307
+ }
308
+
309
+ mermaidLoaderPromise = new Promise((resolve, reject) => {
310
+ const script = document.createElement('script');
311
+ script.src = 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
312
+ script.async = true;
313
+ script.onload = () => {
314
+ if (window.mermaid) {
315
+ window.mermaid.initialize({ startOnLoad: false, securityLevel: 'loose' });
316
+ resolve(window.mermaid);
317
+ } else {
318
+ reject(new Error('Mermaid library not available after load'));
319
+ }
320
+ };
321
+ script.onerror = () => reject(new Error('Could not load Mermaid library'));
322
+ document.head.appendChild(script);
323
+ });
324
+
325
+ return mermaidLoaderPromise;
326
+ }
327
+
328
+ function svgToDataUri(svg) {
329
+ const encoded = window.btoa(unescape(encodeURIComponent(svg)));
330
+ return `data:image/svg+xml;base64,${encoded}`;
331
+ }
332
+
333
+ let overviewRenderedHash = null;
334
+ async function renderOverview() {
335
+ const spec = getSpecFromUi();
336
+ const flows = extractFlowsFromSpec(spec);
337
+ if (!flows.length) return;
338
+
339
+ const mermaid = buildOverviewMermaid(flows);
340
+ const currentHash = `${flows.length}:${mermaid}`;
341
+ if (overviewRenderedHash === currentHash) return;
342
+
343
+ const infoContainer = document.querySelector('.swagger-ui .information-container');
344
+ if (!infoContainer) return;
345
+
346
+ let holder = document.getElementById('xof-overview-holder');
347
+ if (!holder) {
348
+ holder = document.createElement('div');
349
+ holder.id = 'xof-overview-holder';
350
+ holder.className = 'xof-overview xof-card';
351
+ infoContainer.parentNode.insertBefore(holder, infoContainer.nextSibling);
352
+ }
353
+
354
+ holder.innerHTML = '<div class="xof-title">x-openapi-flow — Flow Overview</div><div class="xof-empty">Rendering Mermaid graph...</div>';
355
+
356
+ try {
357
+ const mermaidLib = await ensureMermaid();
358
+ const renderId = `xof-overview-${Date.now()}`;
359
+ const renderResult = await mermaidLib.render(renderId, mermaid);
360
+ const svg = renderResult && renderResult.svg ? renderResult.svg : renderResult;
361
+ const dataUri = svgToDataUri(svg);
362
+
363
+ holder.innerHTML = `
364
+ <div class="xof-title">x-openapi-flow — Flow Overview</div>
365
+ <img src="${dataUri}" alt="x-openapi-flow overview graph" />
366
+ <details style="margin-top:8px;">
367
+ <summary style="cursor:pointer;">Mermaid source</summary>
368
+ <div class="xof-overview-code">${mermaid.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
369
+ </details>
370
+ `;
371
+ } catch (_error) {
372
+ holder.innerHTML = `
373
+ <div class="xof-title">x-openapi-flow — Flow Overview</div>
374
+ <div class="xof-empty">Could not render Mermaid image in this environment.</div>
375
+ <div class="xof-overview-code">${mermaid.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
376
+ `;
377
+ }
378
+
379
+ overviewRenderedHash = currentHash;
380
+ }
381
+
382
+ function findXOpenApiFlowValueCell(opblock) {
383
+ const rows = opblock.querySelectorAll('tr');
384
+ for (const row of rows) {
385
+ const cells = row.querySelectorAll('td');
386
+ if (cells.length < 2) continue;
387
+ if (cells[0].innerText.trim() === 'x-openapi-flow') {
388
+ return cells[1];
389
+ }
390
+ }
391
+ return null;
392
+ }
393
+
394
+ function enhanceOperation(opblock) {
395
+ const valueCell = findXOpenApiFlowValueCell(opblock);
396
+ if (!valueCell || valueCell.dataset.xofEnhanced === '1') return;
397
+
398
+ const raw = valueCell.innerText.trim();
399
+ if (!raw) return;
400
+
401
+ let flow;
402
+ try {
403
+ flow = JSON.parse(raw);
404
+ } catch (_error) {
405
+ return;
406
+ }
407
+
408
+ valueCell.innerHTML = renderCard(flow);
409
+ valueCell.dataset.xofEnhanced = '1';
410
+ }
411
+
412
+ function enhanceAll() {
413
+ injectStyles();
414
+ const opblocks = document.querySelectorAll('.opblock');
415
+ opblocks.forEach((opblock) => enhanceOperation(opblock));
416
+ renderOverview();
417
+ }
418
+
419
+ const observer = new MutationObserver(() => {
420
+ enhanceAll();
421
+ });
422
+
423
+ window.addEventListener('load', () => {
424
+ enhanceAll();
425
+ observer.observe(document.body, { childList: true, subtree: true });
426
+ });
427
+ })();
package/lib/validator.js CHANGED
@@ -149,20 +149,42 @@ function defaultResult(pathValue, ok = true) {
149
149
  duplicate_transitions: [],
150
150
  non_terminating_states: [],
151
151
  invalid_operation_references: [],
152
+ invalid_field_references: [],
152
153
  warnings: [],
153
154
  },
154
155
  };
155
156
  }
156
157
 
158
+ function getOperationsById(api) {
159
+ const operationsById = new Map();
160
+ const paths = (api && api.paths) || {};
161
+ const methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
162
+
163
+ for (const [pathKey, pathItem] of Object.entries(paths)) {
164
+ for (const method of methods) {
165
+ const operation = pathItem[method];
166
+ if (!operation || !operation.operationId) {
167
+ continue;
168
+ }
169
+
170
+ operationsById.set(operation.operationId, {
171
+ operation,
172
+ endpoint: `${method.toUpperCase()} ${pathKey}`,
173
+ });
174
+ }
175
+ }
176
+
177
+ return operationsById;
178
+ }
179
+
157
180
  /**
158
181
  * Detect invalid operationId references declared in transitions.
182
+ * @param {Map<string, { operation: object, endpoint: string }>} operationsById
159
183
  * @param {{ endpoint: string, operation_id?: string, flow: object }[]} flows
160
184
  * @returns {{ type: string, operation_id: string, declared_in: string }[]}
161
185
  */
162
- function detectInvalidOperationReferences(flows) {
163
- const knownOperationIds = new Set(
164
- flows.map(({ operation_id }) => operation_id).filter(Boolean)
165
- );
186
+ function detectInvalidOperationReferences(operationsById, flows) {
187
+ const knownOperationIds = new Set(operationsById.keys());
166
188
 
167
189
  const invalidReferences = [];
168
190
 
@@ -197,6 +219,196 @@ function detectInvalidOperationReferences(flows) {
197
219
  return invalidReferences;
198
220
  }
199
221
 
222
+ function parseFieldReference(refValue) {
223
+ if (typeof refValue !== "string") {
224
+ return null;
225
+ }
226
+
227
+ const match = refValue.match(/^([^:]+):(request\.body|response\.(\d{3}|default)\.body)\.(.+)$/);
228
+ if (!match) {
229
+ return null;
230
+ }
231
+
232
+ const operationId = match[1];
233
+ const scope = match[2];
234
+ const responseCode = match[3];
235
+ const fieldPath = match[4];
236
+
237
+ return {
238
+ operation_id: operationId,
239
+ scope,
240
+ response_code: responseCode,
241
+ field_path: fieldPath,
242
+ };
243
+ }
244
+
245
+ function resolveSchema(api, schema, depth = 0) {
246
+ if (!schema || typeof schema !== "object") {
247
+ return null;
248
+ }
249
+
250
+ if (depth > 10) {
251
+ return null;
252
+ }
253
+
254
+ if (schema.$ref && typeof schema.$ref === "string") {
255
+ const ref = schema.$ref;
256
+ if (!ref.startsWith("#/")) {
257
+ return null;
258
+ }
259
+
260
+ const tokens = ref.slice(2).split("/");
261
+ let target = api;
262
+ for (const token of tokens) {
263
+ if (!target || typeof target !== "object") {
264
+ return null;
265
+ }
266
+ target = target[token];
267
+ }
268
+
269
+ return resolveSchema(api, target, depth + 1);
270
+ }
271
+
272
+ return schema;
273
+ }
274
+
275
+ function hasFieldPath(api, schema, pathTokens) {
276
+ const resolved = resolveSchema(api, schema);
277
+ if (!resolved) {
278
+ return false;
279
+ }
280
+
281
+ if (pathTokens.length === 0) {
282
+ return true;
283
+ }
284
+
285
+ const [currentToken, ...rest] = pathTokens;
286
+
287
+ if (Array.isArray(resolved.anyOf)) {
288
+ return resolved.anyOf.some((item) => hasFieldPath(api, item, pathTokens));
289
+ }
290
+ if (Array.isArray(resolved.oneOf)) {
291
+ return resolved.oneOf.some((item) => hasFieldPath(api, item, pathTokens));
292
+ }
293
+ if (Array.isArray(resolved.allOf)) {
294
+ return resolved.allOf.some((item) => hasFieldPath(api, item, pathTokens));
295
+ }
296
+
297
+ if (resolved.type === "array" && resolved.items) {
298
+ return hasFieldPath(api, resolved.items, pathTokens);
299
+ }
300
+
301
+ if (resolved.properties && typeof resolved.properties === "object") {
302
+ if (!(currentToken in resolved.properties)) {
303
+ return false;
304
+ }
305
+ return hasFieldPath(api, resolved.properties[currentToken], rest);
306
+ }
307
+
308
+ if (resolved.additionalProperties && typeof resolved.additionalProperties === "object") {
309
+ return hasFieldPath(api, resolved.additionalProperties, rest);
310
+ }
311
+
312
+ return false;
313
+ }
314
+
315
+ function resolveFieldReferenceSchema(api, operationsById, parsedRef) {
316
+ const operationInfo = operationsById.get(parsedRef.operation_id);
317
+ if (!operationInfo) {
318
+ return { error: "operation_not_found" };
319
+ }
320
+
321
+ const operation = operationInfo.operation;
322
+ if (parsedRef.scope === "request.body") {
323
+ const requestSchema = operation.requestBody
324
+ && operation.requestBody.content
325
+ && operation.requestBody.content["application/json"]
326
+ && operation.requestBody.content["application/json"].schema;
327
+
328
+ if (!requestSchema) {
329
+ return { error: "request_schema_not_found" };
330
+ }
331
+
332
+ return { schema: requestSchema };
333
+ }
334
+
335
+ const responseCode = parsedRef.response_code;
336
+ const responseSchema = operation.responses
337
+ && operation.responses[responseCode]
338
+ && operation.responses[responseCode].content
339
+ && operation.responses[responseCode].content["application/json"]
340
+ && operation.responses[responseCode].content["application/json"].schema;
341
+
342
+ if (!responseSchema) {
343
+ return { error: "response_schema_not_found" };
344
+ }
345
+
346
+ return { schema: responseSchema };
347
+ }
348
+
349
+ function detectInvalidFieldReferences(api, operationsById, flows) {
350
+ const invalidFieldReferences = [];
351
+
352
+ for (const { endpoint, flow } of flows) {
353
+ const transitions = flow.transitions || [];
354
+
355
+ for (const transition of transitions) {
356
+ const referenceGroups = [
357
+ {
358
+ type: "prerequisite_field_refs",
359
+ refs: Array.isArray(transition.prerequisite_field_refs)
360
+ ? transition.prerequisite_field_refs
361
+ : [],
362
+ },
363
+ {
364
+ type: "propagated_field_refs",
365
+ refs: Array.isArray(transition.propagated_field_refs)
366
+ ? transition.propagated_field_refs
367
+ : [],
368
+ },
369
+ ];
370
+
371
+ for (const group of referenceGroups) {
372
+ for (const refValue of group.refs) {
373
+ const parsedRef = parseFieldReference(refValue);
374
+ if (!parsedRef) {
375
+ invalidFieldReferences.push({
376
+ type: group.type,
377
+ reference: refValue,
378
+ reason: "invalid_format",
379
+ declared_in: endpoint,
380
+ });
381
+ continue;
382
+ }
383
+
384
+ const resolvedSchema = resolveFieldReferenceSchema(api, operationsById, parsedRef);
385
+ if (resolvedSchema.error) {
386
+ invalidFieldReferences.push({
387
+ type: group.type,
388
+ reference: refValue,
389
+ reason: resolvedSchema.error,
390
+ declared_in: endpoint,
391
+ });
392
+ continue;
393
+ }
394
+
395
+ const pathTokens = parsedRef.field_path.split(".").filter(Boolean);
396
+ if (!hasFieldPath(api, resolvedSchema.schema, pathTokens)) {
397
+ invalidFieldReferences.push({
398
+ type: group.type,
399
+ reference: refValue,
400
+ reason: "field_not_found",
401
+ declared_in: endpoint,
402
+ });
403
+ }
404
+ }
405
+ }
406
+ }
407
+ }
408
+
409
+ return invalidFieldReferences;
410
+ }
411
+
200
412
  /**
201
413
  * Verify that every target_state referenced in transitions corresponds to a
202
414
  * current_state defined in at least one endpoint of the same API.
@@ -588,6 +800,7 @@ function run(apiPath, options = {}) {
588
800
  }
589
801
 
590
802
  // 5. Advanced graph checks
803
+ const operationsById = getOperationsById(api);
591
804
  const graph = buildStateGraph(flows);
592
805
  const initialStates = [...graph.nodes].filter(
593
806
  (state) => graph.indegree.get(state) === 0
@@ -599,7 +812,8 @@ function run(apiPath, options = {}) {
599
812
  const cycle = detectCycle(graph);
600
813
  const duplicateTransitions = detectDuplicateTransitions(flows);
601
814
  const terminalCoverage = detectTerminalCoverage(graph);
602
- const invalidOperationReferences = detectInvalidOperationReferences(flows);
815
+ const invalidOperationReferences = detectInvalidOperationReferences(operationsById, flows);
816
+ const invalidFieldReferences = detectInvalidFieldReferences(api, operationsById, flows);
603
817
  const multipleInitialStates = initialStates.length > 1 ? initialStates : [];
604
818
 
605
819
  if (profileConfig.runAdvanced) {
@@ -645,6 +859,12 @@ function run(apiPath, options = {}) {
645
859
  );
646
860
  }
647
861
 
862
+ if (profileConfig.runQuality && invalidFieldReferences.length > 0) {
863
+ qualityWarnings.push(
864
+ `Transition field references not found/invalid: ${invalidFieldReferences.length}`
865
+ );
866
+ }
867
+
648
868
  if (strictQuality && qualityWarnings.length > 0) {
649
869
  hasErrors = true;
650
870
  }
@@ -734,6 +954,7 @@ function run(apiPath, options = {}) {
734
954
  duplicate_transitions: duplicateTransitions,
735
955
  non_terminating_states: terminalCoverage.non_terminating_states,
736
956
  invalid_operation_references: invalidOperationReferences,
957
+ invalid_field_references: invalidFieldReferences,
737
958
  warnings: qualityWarnings,
738
959
  },
739
960
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x-openapi-flow",
3
- "version": "1.1.3",
3
+ "version": "1.2.1",
4
4
  "description": "OpenAPI extension for resource workflow and lifecycle management",
5
5
  "main": "lib/validator.js",
6
6
  "repository": {
@@ -69,6 +69,20 @@
69
69
  "items": {
70
70
  "type": "string"
71
71
  }
72
+ },
73
+ "prerequisite_field_refs": {
74
+ "type": "array",
75
+ "description": "Field references required before this transition. Format: operationId:request.body.field or operationId:response.<status>.body.field",
76
+ "items": {
77
+ "type": "string"
78
+ }
79
+ },
80
+ "propagated_field_refs": {
81
+ "type": "array",
82
+ "description": "Field references produced/propagated for downstream flows. Format: operationId:request.body.field or operationId:response.<status>.body.field",
83
+ "items": {
84
+ "type": "string"
85
+ }
72
86
  }
73
87
  },
74
88
  "additionalProperties": false