x-openapi-flow 1.1.3 → 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 +24 -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 +226 -5
- package/package.json +1 -1
- package/schema/flow-schema.json +14 -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
|
|
@@ -60,6 +75,13 @@ Create `x-openapi-flow.config.json` in your project directory:
|
|
|
60
75
|
|
|
61
76
|
- `next_operation_id`: operationId usually called for the next state transition
|
|
62
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`
|
|
63
85
|
|
|
64
86
|
## Swagger UI
|
|
65
87
|
|
|
@@ -67,6 +89,8 @@ Create `x-openapi-flow.config.json` in your project directory:
|
|
|
67
89
|
- For UI interpretation of `x-openapi-flow`, use `showExtensions: true` plus the example plugin at `examples/swagger-ui/x-openapi-flow-plugin.js`.
|
|
68
90
|
- A ready HTML example is available at `examples/swagger-ui/index.html`.
|
|
69
91
|
|
|
92
|
+

|
|
93
|
+
|
|
70
94
|
## Graph Output Example
|
|
71
95
|
|
|
72
96
|
`x-openapi-flow graph` includes transition guidance labels in Mermaid output when present (`next_operation_id`, `prerequisite_operation_ids`).
|
|
@@ -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
|
@@ -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
package/schema/flow-schema.json
CHANGED
|
@@ -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
|