x-openapi-flow 1.2.2 → 1.3.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/lib/validator.js CHANGED
@@ -169,6 +169,8 @@ function getOperationsById(api) {
169
169
 
170
170
  operationsById.set(operation.operationId, {
171
171
  operation,
172
+ path_item: pathItem,
173
+ path_key: pathKey,
172
174
  endpoint: `${method.toUpperCase()} ${pathKey}`,
173
175
  });
174
176
  }
@@ -224,15 +226,15 @@ function parseFieldReference(refValue) {
224
226
  return null;
225
227
  }
226
228
 
227
- const match = refValue.match(/^([^:]+):(request\.body|response\.(\d{3}|default)\.body)\.(.+)$/);
229
+ const match = refValue.match(/^([^:]+):(request\.(body|path)|response\.(\d{3}|default)\.body)\.(.+)$/);
228
230
  if (!match) {
229
231
  return null;
230
232
  }
231
233
 
232
234
  const operationId = match[1];
233
235
  const scope = match[2];
234
- const responseCode = match[3];
235
- const fieldPath = match[4];
236
+ const responseCode = match[4];
237
+ const fieldPath = match[5];
236
238
 
237
239
  return {
238
240
  operation_id: operationId,
@@ -332,6 +334,34 @@ function resolveFieldReferenceSchema(api, operationsById, parsedRef) {
332
334
  return { schema: requestSchema };
333
335
  }
334
336
 
337
+ if (parsedRef.scope === "request.path") {
338
+ const pathLevelParams = Array.isArray(operationInfo.path_item && operationInfo.path_item.parameters)
339
+ ? operationInfo.path_item.parameters
340
+ : [];
341
+ const operationLevelParams = Array.isArray(operation.parameters)
342
+ ? operation.parameters
343
+ : [];
344
+
345
+ const allParams = [...pathLevelParams, ...operationLevelParams].filter(
346
+ (param) => param && typeof param === "object" && param.in === "path" && param.name
347
+ );
348
+
349
+ if (!allParams.length) {
350
+ return { error: "path_parameters_not_found" };
351
+ }
352
+
353
+ const pathSchema = {
354
+ type: "object",
355
+ properties: {},
356
+ };
357
+
358
+ for (const param of allParams) {
359
+ pathSchema.properties[param.name] = param.schema || { type: "string" };
360
+ }
361
+
362
+ return { schema: pathSchema };
363
+ }
364
+
335
365
  const responseCode = parsedRef.response_code;
336
366
  const responseSchema = operation.responses
337
367
  && operation.responses[responseCode]
@@ -985,5 +1015,8 @@ module.exports = {
985
1015
  validateFlows,
986
1016
  detectOrphanStates,
987
1017
  buildStateGraph,
1018
+ detectDuplicateTransitions,
1019
+ detectInvalidOperationReferences,
1020
+ detectTerminalCoverage,
988
1021
  run,
989
1022
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x-openapi-flow",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "OpenAPI extension for resource workflow and lifecycle management",
5
5
  "main": "lib/validator.js",
6
6
  "repository": {
@@ -29,8 +29,9 @@
29
29
  "x-openapi-flow": "bin/x-openapi-flow.js"
30
30
  },
31
31
  "scripts": {
32
- "test": "npm run test:cli && npm run test:smoke",
32
+ "test": "npm run test:cli && npm run test:ui && npm run test:smoke",
33
33
  "test:cli": "node --test tests/cli.test.js",
34
+ "test:ui": "node --test tests/plugin-ui.test.js",
34
35
  "test:smoke": "node bin/x-openapi-flow.js validate examples/payment-api.yaml --profile strict"
35
36
  },
36
37
  "keywords": [
@@ -72,14 +72,14 @@
72
72
  },
73
73
  "prerequisite_field_refs": {
74
74
  "type": "array",
75
- "description": "Field references required before this transition. Format: operationId:request.body.field or operationId:response.<status>.body.field",
75
+ "description": "Field references required before this transition. Format: operationId:request.body.field, operationId:request.path.paramName, or operationId:response.<status>.body.field",
76
76
  "items": {
77
77
  "type": "string"
78
78
  }
79
79
  },
80
80
  "propagated_field_refs": {
81
81
  "type": "array",
82
- "description": "Field references produced/propagated for downstream flows. Format: operationId:request.body.field or operationId:response.<status>.body.field",
82
+ "description": "Field references produced/propagated for downstream flows. Format: operationId:request.body.field, operationId:request.path.paramName, or operationId:response.<status>.body.field",
83
83
  "items": {
84
84
  "type": "string"
85
85
  }
@@ -1,446 +0,0 @@
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
-
51
- return {
52
- wrapComponents: {
53
- OperationSummary: (Original) => (props) => {
54
- const operation = props.operation;
55
- const flow = operation && operation.get && operation.get("x-openapi-flow");
56
-
57
- if (!flow) {
58
- return h(Original, props);
59
- }
60
-
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;
102
-
103
- return h(
104
- "div",
105
- null,
106
- h(Original, props),
107
- h(
108
- "div",
109
- {
110
- style: {
111
- marginTop: "8px",
112
- padding: "10px",
113
- border: "1px solid rgba(255,255,255,0.28)",
114
- borderRadius: "8px",
115
- background: "rgba(0,0,0,0.12)",
116
- fontSize: "12px",
117
- },
118
- },
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(
125
- "div",
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
137
- )
138
- );
139
- },
140
- },
141
- };
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
- let overviewRenderInProgress = false;
335
- let overviewPendingHash = null;
336
- async function renderOverview() {
337
- const spec = getSpecFromUi();
338
- const flows = extractFlowsFromSpec(spec);
339
- if (!flows.length) return;
340
-
341
- const mermaid = buildOverviewMermaid(flows);
342
- const currentHash = `${flows.length}:${mermaid}`;
343
- if (overviewRenderedHash === currentHash) return;
344
- if (overviewRenderInProgress && overviewPendingHash === currentHash) return;
345
-
346
- const infoContainer = document.querySelector('.swagger-ui .information-container');
347
- if (!infoContainer) return;
348
-
349
- let holder = document.getElementById('xof-overview-holder');
350
- if (!holder) {
351
- holder = document.createElement('div');
352
- holder.id = 'xof-overview-holder';
353
- holder.className = 'xof-overview xof-card';
354
- infoContainer.parentNode.insertBefore(holder, infoContainer.nextSibling);
355
- }
356
-
357
- holder.innerHTML = '<div class="xof-title">x-openapi-flow — Flow Overview</div><div class="xof-empty">Rendering Mermaid graph...</div>';
358
- overviewRenderInProgress = true;
359
- overviewPendingHash = currentHash;
360
-
361
- try {
362
- const mermaidLib = await ensureMermaid();
363
- const renderId = `xof-overview-${Date.now()}`;
364
- const renderResult = await mermaidLib.render(renderId, mermaid);
365
- const svg = renderResult && renderResult.svg ? renderResult.svg : renderResult;
366
- const dataUri = svgToDataUri(svg);
367
-
368
- holder.innerHTML = `
369
- <div class="xof-title">x-openapi-flow — Flow Overview</div>
370
- <img src="${dataUri}" alt="x-openapi-flow overview graph" />
371
- <details style="margin-top:8px;">
372
- <summary style="cursor:pointer;">Mermaid source</summary>
373
- <div class="xof-overview-code">${mermaid.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
374
- </details>
375
- `;
376
- } catch (_error) {
377
- holder.innerHTML = `
378
- <div class="xof-title">x-openapi-flow — Flow Overview</div>
379
- <div class="xof-empty">Could not render Mermaid image in this environment.</div>
380
- <div class="xof-overview-code">${mermaid.replace(/</g, '&lt;').replace(/>/g, '&gt;')}</div>
381
- `;
382
- } finally {
383
- overviewRenderInProgress = false;
384
- }
385
-
386
- overviewRenderedHash = currentHash;
387
- }
388
-
389
- function findXOpenApiFlowValueCell(opblock) {
390
- const rows = opblock.querySelectorAll('tr');
391
- for (const row of rows) {
392
- const cells = row.querySelectorAll('td');
393
- if (cells.length < 2) continue;
394
- if (cells[0].innerText.trim() === 'x-openapi-flow') {
395
- return cells[1];
396
- }
397
- }
398
- return null;
399
- }
400
-
401
- function enhanceOperation(opblock) {
402
- const valueCell = findXOpenApiFlowValueCell(opblock);
403
- if (!valueCell || valueCell.dataset.xofEnhanced === '1') return;
404
-
405
- const raw = valueCell.innerText.trim();
406
- if (!raw) return;
407
-
408
- let flow;
409
- try {
410
- flow = JSON.parse(raw);
411
- } catch (_error) {
412
- return;
413
- }
414
-
415
- valueCell.innerHTML = renderCard(flow);
416
- valueCell.dataset.xofEnhanced = '1';
417
- }
418
-
419
- function enhanceAll() {
420
- injectStyles();
421
- const opblocks = document.querySelectorAll('.opblock');
422
- opblocks.forEach((opblock) => enhanceOperation(opblock));
423
- renderOverview().catch(() => {
424
- // keep plugin resilient in environments where async rendering fails
425
- });
426
- }
427
-
428
- let enhanceScheduled = false;
429
- function scheduleEnhance() {
430
- if (enhanceScheduled) return;
431
- enhanceScheduled = true;
432
- window.requestAnimationFrame(() => {
433
- enhanceScheduled = false;
434
- enhanceAll();
435
- });
436
- }
437
-
438
- const observer = new MutationObserver(() => {
439
- scheduleEnhance();
440
- });
441
-
442
- window.addEventListener('load', () => {
443
- scheduleEnhance();
444
- observer.observe(document.body, { childList: true, subtree: true });
445
- });
446
- })();