x-openapi-flow 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,21 @@ CLI and specification for validating the `x-openapi-flow` extension field in Ope
8
8
  npm install x-openapi-flow
9
9
  ```
10
10
 
11
+ Optional mirror on GitHub Packages (default usage remains unscoped on npm):
12
+
13
+ ```bash
14
+ npm config set @tiago-marques:registry https://npm.pkg.github.com
15
+ npm install @tiago-marques/x-openapi-flow
16
+ ```
17
+
18
+ If authentication is required, include this in your `.npmrc`:
19
+
20
+ ```ini
21
+ //npm.pkg.github.com/:_authToken=${GH_PACKAGES_TOKEN}
22
+ ```
23
+
24
+ Use a GitHub PAT with `read:packages` (install) and `write:packages` (publish).
25
+
11
26
  ## Quick Usage
12
27
 
13
28
  ```bash
@@ -56,12 +71,32 @@ Create `x-openapi-flow.config.json` in your project directory:
56
71
  - OpenAPI input in `.yaml`, `.yml`, and `.json`
57
72
  - Validation processes OAS content with the `x-openapi-flow` extension
58
73
 
74
+ ### Optional Transition Guidance Fields
75
+
76
+ - `next_operation_id`: operationId usually called for the next state transition
77
+ - `prerequisite_operation_ids`: operationIds expected before a transition
78
+ - `prerequisite_field_refs`: required field refs before transition
79
+ - `propagated_field_refs`: field refs used by downstream flows
80
+
81
+ Field reference format:
82
+
83
+ - `operationId:request.body.field`
84
+ - `operationId:response.<status>.body.field`
85
+
59
86
  ## Swagger UI
60
87
 
61
88
  - There is no Swagger UI-based automated test in this repo today (tests are CLI-only).
62
89
  - For UI interpretation of `x-openapi-flow`, use `showExtensions: true` plus the example plugin at `examples/swagger-ui/x-openapi-flow-plugin.js`.
63
90
  - A ready HTML example is available at `examples/swagger-ui/index.html`.
64
91
 
92
+ ![Swagger UI integration result](../docs/assets/swagger-ui-integration-result-v2.svg)
93
+
94
+ ## Graph Output Example
95
+
96
+ `x-openapi-flow graph` includes transition guidance labels in Mermaid output when present (`next_operation_id`, `prerequisite_operation_ids`).
97
+
98
+ ![Guided graph example](../docs/assets/graph-order-guided.svg)
99
+
65
100
  ## Repository and Full Documentation
66
101
 
67
102
  - Repository: https://github.com/tiago-marques/x-openapi-flow
@@ -8,7 +8,6 @@ const {
8
8
  run,
9
9
  loadApi,
10
10
  extractFlows,
11
- buildStateGraph,
12
11
  } = require("../lib/validator");
13
12
 
14
13
  const DEFAULT_CONFIG_NAME = "x-openapi-flow.config.json";
@@ -702,25 +701,61 @@ function runDoctor(parsed) {
702
701
  function buildMermaidGraph(filePath) {
703
702
  const api = loadApi(filePath);
704
703
  const flows = extractFlows(api);
705
- const graph = buildStateGraph(flows);
706
704
  const lines = ["stateDiagram-v2"];
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
+ }
707
719
 
708
- for (const state of graph.nodes) {
709
- lines.push(` state ${state}`);
710
- }
720
+ nodes.add(to);
711
721
 
712
- for (const [from, targets] of graph.adjacency.entries()) {
713
- for (const to of targets) {
714
- lines.push(` ${from} --> ${to}`);
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}` : ""}`);
715
748
  }
716
749
  }
717
750
 
751
+ for (const state of nodes) {
752
+ lines.splice(1, 0, ` state ${state}`);
753
+ }
754
+
718
755
  return {
719
756
  flowCount: flows.length,
720
- nodes: [...graph.nodes],
721
- edges: [...graph.adjacency.entries()].flatMap(([from, targets]) =>
722
- [...targets].map((to) => ({ from, to }))
723
- ),
757
+ nodes: [...nodes],
758
+ edges,
724
759
  mermaid: lines.join("\n"),
725
760
  };
726
761
  }
@@ -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.
@@ -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
@@ -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
@@ -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,262 @@
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;
15
65
 
16
- return React.createElement(
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;
102
+
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
+ `;
164
+
165
+ document.head.appendChild(style);
166
+ }
167
+
168
+ function text(value) {
169
+ if (value === null || value === undefined || value === '') return '-';
170
+ if (Array.isArray(value)) return value.length ? value.join(', ') : '-';
171
+ return String(value);
172
+ }
173
+
174
+ function renderTransitions(currentState, transitions) {
175
+ if (!Array.isArray(transitions) || transitions.length === 0) {
176
+ return '<div class="xof-empty">No transitions (terminal state)</div>';
177
+ }
178
+
179
+ return `<ul class="xof-list">${transitions
180
+ .map((transition) => {
181
+ const condition = transition.condition ? ` — ${text(transition.condition)}` : '';
182
+ const nextOperation = transition.next_operation_id ? ` (next: ${text(transition.next_operation_id)})` : '';
183
+ return `<li><strong>${text(transition.trigger_type)}</strong> → <strong>${text(transition.target_state)}</strong>${condition}${nextOperation}</li>`;
184
+ })
185
+ .join('')}</ul>`;
186
+ }
187
+
188
+ function renderGraph(currentState, transitions) {
189
+ if (!Array.isArray(transitions) || transitions.length === 0) {
190
+ return `<div class="xof-edge">${text(currentState)} [terminal]</div>`;
191
+ }
192
+
193
+ return transitions
194
+ .map((transition) => `<div class="xof-edge">${text(currentState)} --> ${text(transition.target_state)} [${text(transition.trigger_type)}]</div>`)
195
+ .join('');
196
+ }
197
+
198
+ function renderCard(flow) {
199
+ const transitions = Array.isArray(flow.transitions) ? flow.transitions : [];
200
+ return `
201
+ <div class="xof-card">
202
+ <div class="xof-title">x-openapi-flow</div>
203
+ <div class="xof-meta">
204
+ <div class="xof-meta-label">version</div><div>${text(flow.version)}</div>
205
+ <div class="xof-meta-label">id</div><div>${text(flow.id)}</div>
206
+ <div class="xof-meta-label">current_state</div><div>${text(flow.current_state)}</div>
207
+ </div>
208
+ <div><strong>Transitions</strong></div>
209
+ ${renderTransitions(flow.current_state, transitions)}
210
+ <div class="xof-graph">
211
+ <div class="xof-graph-title">Flow graph (operation-level)</div>
212
+ ${renderGraph(flow.current_state, transitions)}
213
+ </div>
214
+ </div>
215
+ `;
216
+ }
217
+
218
+ function findXOpenApiFlowValueCell(opblock) {
219
+ const rows = opblock.querySelectorAll('tr');
220
+ for (const row of rows) {
221
+ const cells = row.querySelectorAll('td');
222
+ if (cells.length < 2) continue;
223
+ if (cells[0].innerText.trim() === 'x-openapi-flow') {
224
+ return cells[1];
225
+ }
226
+ }
227
+ return null;
228
+ }
229
+
230
+ function enhanceOperation(opblock) {
231
+ const valueCell = findXOpenApiFlowValueCell(opblock);
232
+ if (!valueCell || valueCell.dataset.xofEnhanced === '1') return;
233
+
234
+ const raw = valueCell.innerText.trim();
235
+ if (!raw) return;
236
+
237
+ let flow;
238
+ try {
239
+ flow = JSON.parse(raw);
240
+ } catch (_error) {
241
+ return;
242
+ }
243
+
244
+ valueCell.innerHTML = renderCard(flow);
245
+ valueCell.dataset.xofEnhanced = '1';
246
+ }
247
+
248
+ function enhanceAll() {
249
+ injectStyles();
250
+ const opblocks = document.querySelectorAll('.opblock');
251
+ opblocks.forEach((opblock) => enhanceOperation(opblock));
252
+ }
253
+
254
+ const observer = new MutationObserver(() => {
255
+ enhanceAll();
256
+ });
257
+
258
+ window.addEventListener('load', () => {
259
+ enhanceAll();
260
+ observer.observe(document.body, { childList: true, subtree: true });
261
+ });
262
+ })();
package/lib/validator.js CHANGED
@@ -40,7 +40,7 @@ function loadApi(filePath) {
40
40
  /**
41
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 = [];
@@ -62,6 +62,7 @@ function extractFlows(api) {
62
62
  if (operation && operation["x-openapi-flow"]) {
63
63
  entries.push({
64
64
  endpoint: `${method.toUpperCase()} ${pathKey}`,
65
+ operation_id: operation.operationId,
65
66
  flow: operation["x-openapi-flow"],
66
67
  });
67
68
  }
@@ -147,11 +148,267 @@ function defaultResult(pathValue, ok = true) {
147
148
  multiple_initial_states: [],
148
149
  duplicate_transitions: [],
149
150
  non_terminating_states: [],
151
+ invalid_operation_references: [],
152
+ invalid_field_references: [],
150
153
  warnings: [],
151
154
  },
152
155
  };
153
156
  }
154
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
+
180
+ /**
181
+ * Detect invalid operationId references declared in transitions.
182
+ * @param {Map<string, { operation: object, endpoint: string }>} operationsById
183
+ * @param {{ endpoint: string, operation_id?: string, flow: object }[]} flows
184
+ * @returns {{ type: string, operation_id: string, declared_in: string }[]}
185
+ */
186
+ function detectInvalidOperationReferences(operationsById, flows) {
187
+ const knownOperationIds = new Set(operationsById.keys());
188
+
189
+ const invalidReferences = [];
190
+
191
+ for (const { endpoint, flow } of flows) {
192
+ const transitions = flow.transitions || [];
193
+
194
+ for (const transition of transitions) {
195
+ if (transition.next_operation_id && !knownOperationIds.has(transition.next_operation_id)) {
196
+ invalidReferences.push({
197
+ type: "next_operation_id",
198
+ operation_id: transition.next_operation_id,
199
+ declared_in: endpoint,
200
+ });
201
+ }
202
+
203
+ const prerequisites = Array.isArray(transition.prerequisite_operation_ids)
204
+ ? transition.prerequisite_operation_ids
205
+ : [];
206
+
207
+ for (const prerequisiteOperationId of prerequisites) {
208
+ if (!knownOperationIds.has(prerequisiteOperationId)) {
209
+ invalidReferences.push({
210
+ type: "prerequisite_operation_ids",
211
+ operation_id: prerequisiteOperationId,
212
+ declared_in: endpoint,
213
+ });
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ return invalidReferences;
220
+ }
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
+
155
412
  /**
156
413
  * Verify that every target_state referenced in transitions corresponds to a
157
414
  * current_state defined in at least one endpoint of the same API.
@@ -543,6 +800,7 @@ function run(apiPath, options = {}) {
543
800
  }
544
801
 
545
802
  // 5. Advanced graph checks
803
+ const operationsById = getOperationsById(api);
546
804
  const graph = buildStateGraph(flows);
547
805
  const initialStates = [...graph.nodes].filter(
548
806
  (state) => graph.indegree.get(state) === 0
@@ -554,6 +812,8 @@ function run(apiPath, options = {}) {
554
812
  const cycle = detectCycle(graph);
555
813
  const duplicateTransitions = detectDuplicateTransitions(flows);
556
814
  const terminalCoverage = detectTerminalCoverage(graph);
815
+ const invalidOperationReferences = detectInvalidOperationReferences(operationsById, flows);
816
+ const invalidFieldReferences = detectInvalidFieldReferences(api, operationsById, flows);
557
817
  const multipleInitialStates = initialStates.length > 1 ? initialStates : [];
558
818
 
559
819
  if (profileConfig.runAdvanced) {
@@ -590,6 +850,21 @@ function run(apiPath, options = {}) {
590
850
  );
591
851
  }
592
852
 
853
+ if (profileConfig.runQuality && invalidOperationReferences.length > 0) {
854
+ const invalidOperationIds = [
855
+ ...new Set(invalidOperationReferences.map((item) => item.operation_id)),
856
+ ];
857
+ qualityWarnings.push(
858
+ `Transition operation references not found: ${invalidOperationIds.join(", ")}`
859
+ );
860
+ }
861
+
862
+ if (profileConfig.runQuality && invalidFieldReferences.length > 0) {
863
+ qualityWarnings.push(
864
+ `Transition field references not found/invalid: ${invalidFieldReferences.length}`
865
+ );
866
+ }
867
+
593
868
  if (strictQuality && qualityWarnings.length > 0) {
594
869
  hasErrors = true;
595
870
  }
@@ -678,6 +953,8 @@ function run(apiPath, options = {}) {
678
953
  multiple_initial_states: multipleInitialStates,
679
954
  duplicate_transitions: duplicateTransitions,
680
955
  non_terminating_states: terminalCoverage.non_terminating_states,
956
+ invalid_operation_references: invalidOperationReferences,
957
+ invalid_field_references: invalidFieldReferences,
681
958
  warnings: qualityWarnings,
682
959
  },
683
960
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x-openapi-flow",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "OpenAPI extension for resource workflow and lifecycle management",
5
5
  "main": "lib/validator.js",
6
6
  "repository": {
@@ -58,6 +58,31 @@
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
+ }
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
+ }
61
86
  }
62
87
  },
63
88
  "additionalProperties": false