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 +35 -0
- package/bin/x-openapi-flow.js +47 -12
- package/examples/order-api.yaml +8 -0
- package/examples/payment-api.yaml +6 -0
- package/examples/swagger-ui/index.html +3 -0
- package/examples/swagger-ui/x-openapi-flow-plugin.js +236 -20
- package/lib/validator.js +278 -1
- package/package.json +1 -1
- package/schema/flow-schema.json +25 -0
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
|
+

|
|
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
|
+

|
|
99
|
+
|
|
65
100
|
## Repository and Full Documentation
|
|
66
101
|
|
|
67
102
|
- Repository: https://github.com/tiago-marques/x-openapi-flow
|
package/bin/x-openapi-flow.js
CHANGED
|
@@ -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
|
-
|
|
709
|
-
lines.push(` state ${state}`);
|
|
710
|
-
}
|
|
720
|
+
nodes.add(to);
|
|
711
721
|
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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: [...
|
|
721
|
-
edges
|
|
722
|
-
[...targets].map((to) => ({ from, to }))
|
|
723
|
-
),
|
|
757
|
+
nodes: [...nodes],
|
|
758
|
+
edges,
|
|
724
759
|
mermaid: lines.join("\n"),
|
|
725
760
|
};
|
|
726
761
|
}
|
package/examples/order-api.yaml
CHANGED
|
@@ -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
|
|
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
|
|
58
|
+
return h(Original, props);
|
|
10
59
|
}
|
|
11
60
|
|
|
12
|
-
const flowObject = flow
|
|
13
|
-
const currentState = flowObject.current_state
|
|
14
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
106
|
+
h(Original, props),
|
|
107
|
+
h(
|
|
21
108
|
"div",
|
|
22
109
|
{
|
|
23
110
|
style: {
|
|
24
111
|
marginTop: "8px",
|
|
25
|
-
padding: "
|
|
26
|
-
border: "1px solid
|
|
27
|
-
borderRadius: "
|
|
28
|
-
background: "
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
{
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
package/schema/flow-schema.json
CHANGED
|
@@ -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
|