x-openapi-flow 1.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 @metiagomarques
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # x-openapi-flow
2
+
3
+ CLI and specification for validating the `x-flow` extension field in OpenAPI documents.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install x-openapi-flow
9
+ ```
10
+
11
+ ## Quick Usage
12
+
13
+ ```bash
14
+ x-openapi-flow validate openapi.yaml
15
+ x-openapi-flow graph openapi.yaml
16
+ x-openapi-flow doctor
17
+ ```
18
+
19
+ ## Commands
20
+
21
+ ```bash
22
+ x-openapi-flow validate <openapi-file> [--format pretty|json] [--profile core|relaxed|strict] [--strict-quality] [--config path]
23
+ x-openapi-flow init [output-file] [--title "My API"]
24
+ x-openapi-flow graph <openapi-file> [--format mermaid|json]
25
+ x-openapi-flow doctor [--config path]
26
+ ```
27
+
28
+ ## Optional Configuration
29
+
30
+ Create `x-openapi-flow.config.json` in your project directory:
31
+
32
+ ```json
33
+ {
34
+ "profile": "strict",
35
+ "format": "pretty",
36
+ "strictQuality": false
37
+ }
38
+ ```
39
+
40
+ ## File Compatibility
41
+
42
+ - OpenAPI input in `.yaml`, `.yml`, and `.json`
43
+ - Validation processes OAS content with the `x-flow` extension
44
+
45
+ ## Repository and Full Documentation
46
+
47
+ - Repository: https://github.com/tiago-marques/x-openapi-flow
48
+ - Full guide and changelog are available in the root repository.
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const {
7
+ run,
8
+ loadApi,
9
+ extractFlows,
10
+ buildStateGraph,
11
+ } = require("../lib/validator");
12
+
13
+ const DEFAULT_CONFIG_NAME = "x-openapi-flow.config.json";
14
+
15
+ function resolveConfigPath(configPathArg) {
16
+ return configPathArg
17
+ ? path.resolve(configPathArg)
18
+ : path.resolve(process.cwd(), DEFAULT_CONFIG_NAME);
19
+ }
20
+
21
+ function loadConfig(configPathArg) {
22
+ const configPath = resolveConfigPath(configPathArg);
23
+ if (!fs.existsSync(configPath)) {
24
+ return { path: configPath, exists: false, data: {} };
25
+ }
26
+
27
+ try {
28
+ const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
29
+ return { path: configPath, exists: true, data: parsed };
30
+ } catch (err) {
31
+ return {
32
+ path: configPath,
33
+ exists: true,
34
+ error: `Could not parse config file: ${err.message}`,
35
+ data: {},
36
+ };
37
+ }
38
+ }
39
+
40
+ function printHelp() {
41
+ console.log(`x-openapi-flow CLI
42
+
43
+ Usage:
44
+ x-openapi-flow validate <openapi-file> [--format pretty|json] [--profile core|relaxed|strict] [--strict-quality] [--config path]
45
+ x-openapi-flow init [output-file] [--title "My API"]
46
+ x-openapi-flow graph <openapi-file> [--format mermaid|json]
47
+ x-openapi-flow doctor [--config path]
48
+ x-openapi-flow --help
49
+
50
+ Examples:
51
+ x-openapi-flow validate examples/order-api.yaml
52
+ x-openapi-flow validate examples/order-api.yaml --profile relaxed
53
+ x-openapi-flow validate examples/order-api.yaml --strict-quality
54
+ x-openapi-flow init my-api.yaml --title "Orders API"
55
+ x-openapi-flow graph examples/order-api.yaml
56
+ x-openapi-flow doctor
57
+ `);
58
+ }
59
+
60
+ function getOptionValue(args, optionName) {
61
+ const index = args.indexOf(optionName);
62
+ if (index === -1) {
63
+ return { found: false };
64
+ }
65
+
66
+ const value = args[index + 1];
67
+ if (!value || value.startsWith("--")) {
68
+ return { found: true, error: `Missing value for ${optionName}.` };
69
+ }
70
+
71
+ return { found: true, value, index };
72
+ }
73
+
74
+ function findUnknownOptions(args, knownOptionsWithValue, knownFlags) {
75
+ for (let index = 0; index < args.length; index += 1) {
76
+ const token = args[index];
77
+ if (!token.startsWith("--")) {
78
+ continue;
79
+ }
80
+
81
+ if (knownFlags.includes(token)) {
82
+ continue;
83
+ }
84
+
85
+ if (knownOptionsWithValue.includes(token)) {
86
+ index += 1;
87
+ continue;
88
+ }
89
+
90
+ return token;
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ function parseValidateArgs(args) {
97
+ const unknown = findUnknownOptions(
98
+ args,
99
+ ["--format", "--profile", "--config"],
100
+ ["--strict-quality"]
101
+ );
102
+ if (unknown) {
103
+ return { error: `Unknown option: ${unknown}` };
104
+ }
105
+
106
+ const formatOpt = getOptionValue(args, "--format");
107
+ if (formatOpt.error) {
108
+ return { error: `${formatOpt.error} Use 'pretty' or 'json'.` };
109
+ }
110
+
111
+ const profileOpt = getOptionValue(args, "--profile");
112
+ if (profileOpt.error) {
113
+ return { error: `${profileOpt.error} Use 'core', 'relaxed', or 'strict'.` };
114
+ }
115
+
116
+ const configOpt = getOptionValue(args, "--config");
117
+ if (configOpt.error) {
118
+ return { error: configOpt.error };
119
+ }
120
+
121
+ const strictQuality = args.includes("--strict-quality");
122
+ const format = formatOpt.found ? formatOpt.value : undefined;
123
+ const profile = profileOpt.found ? profileOpt.value : undefined;
124
+
125
+ if (format && !["pretty", "json"].includes(format)) {
126
+ return { error: `Invalid --format '${format}'. Use 'pretty' or 'json'.` };
127
+ }
128
+
129
+ if (profile && !["core", "relaxed", "strict"].includes(profile)) {
130
+ return { error: `Invalid --profile '${profile}'. Use 'core', 'relaxed', or 'strict'.` };
131
+ }
132
+
133
+ const positional = args.filter((token, index) => {
134
+ if (["--format", "--profile", "--config"].includes(token)) {
135
+ return false;
136
+ }
137
+
138
+ if (
139
+ index > 0 &&
140
+ ["--format", "--profile", "--config"].includes(args[index - 1])
141
+ ) {
142
+ return false;
143
+ }
144
+
145
+ return !token.startsWith("--") || token === "-";
146
+ });
147
+
148
+ if (positional.length === 0) {
149
+ return { error: "Missing OpenAPI file path. Usage: x-openapi-flow validate <openapi-file>" };
150
+ }
151
+
152
+ if (positional.length > 1) {
153
+ return { error: `Unexpected argument: ${positional[1]}` };
154
+ }
155
+
156
+ return {
157
+ filePath: path.resolve(positional[0]),
158
+ strictQuality,
159
+ format,
160
+ profile,
161
+ configPath: configOpt.found ? configOpt.value : undefined,
162
+ };
163
+ }
164
+
165
+ function parseInitArgs(args) {
166
+ const unknown = findUnknownOptions(args, ["--title"], []);
167
+ if (unknown) {
168
+ return { error: `Unknown option: ${unknown}` };
169
+ }
170
+
171
+ const titleOpt = getOptionValue(args, "--title");
172
+ if (titleOpt.error) {
173
+ return { error: titleOpt.error };
174
+ }
175
+
176
+ const positional = args.filter((token, index) => {
177
+ if (token === "--title") {
178
+ return false;
179
+ }
180
+ if (index > 0 && args[index - 1] === "--title") {
181
+ return false;
182
+ }
183
+ return !token.startsWith("--");
184
+ });
185
+
186
+ if (positional.length > 1) {
187
+ return { error: `Unexpected argument: ${positional[1]}` };
188
+ }
189
+
190
+ return {
191
+ outputFile: path.resolve(positional[0] || "x-flow-api.yaml"),
192
+ title: titleOpt.found ? titleOpt.value : "Sample API",
193
+ };
194
+ }
195
+
196
+ function parseGraphArgs(args) {
197
+ const unknown = findUnknownOptions(args, ["--format"], []);
198
+ if (unknown) {
199
+ return { error: `Unknown option: ${unknown}` };
200
+ }
201
+
202
+ const formatOpt = getOptionValue(args, "--format");
203
+ if (formatOpt.error) {
204
+ return { error: `${formatOpt.error} Use 'mermaid' or 'json'.` };
205
+ }
206
+
207
+ const format = formatOpt.found ? formatOpt.value : "mermaid";
208
+ if (!["mermaid", "json"].includes(format)) {
209
+ return { error: `Invalid --format '${format}'. Use 'mermaid' or 'json'.` };
210
+ }
211
+
212
+ const positional = args.filter((token, index) => {
213
+ if (token === "--format") {
214
+ return false;
215
+ }
216
+ if (index > 0 && args[index - 1] === "--format") {
217
+ return false;
218
+ }
219
+ return !token.startsWith("--");
220
+ });
221
+
222
+ if (positional.length === 0) {
223
+ return { error: "Missing OpenAPI file path. Usage: x-openapi-flow graph <openapi-file>" };
224
+ }
225
+
226
+ if (positional.length > 1) {
227
+ return { error: `Unexpected argument: ${positional[1]}` };
228
+ }
229
+
230
+ return { filePath: path.resolve(positional[0]), format };
231
+ }
232
+
233
+ function parseDoctorArgs(args) {
234
+ const unknown = findUnknownOptions(args, ["--config"], []);
235
+ if (unknown) {
236
+ return { error: `Unknown option: ${unknown}` };
237
+ }
238
+
239
+ const configOpt = getOptionValue(args, "--config");
240
+ if (configOpt.error) {
241
+ return { error: configOpt.error };
242
+ }
243
+
244
+ return {
245
+ configPath: configOpt.found ? configOpt.value : undefined,
246
+ };
247
+ }
248
+
249
+ function parseArgs(argv) {
250
+ const args = argv.slice(2);
251
+ const command = args[0];
252
+
253
+ if (!command || command === "--help" || command === "-h") {
254
+ return { help: true };
255
+ }
256
+
257
+ const commandArgs = args.slice(1);
258
+ if (commandArgs.includes("--help") || commandArgs.includes("-h")) {
259
+ return { help: true, command };
260
+ }
261
+
262
+ if (command === "validate") {
263
+ const parsed = parseValidateArgs(commandArgs);
264
+ return parsed.error ? parsed : { command, ...parsed };
265
+ }
266
+
267
+ if (command === "init") {
268
+ const parsed = parseInitArgs(commandArgs);
269
+ return parsed.error ? parsed : { command, ...parsed };
270
+ }
271
+
272
+ if (command === "graph") {
273
+ const parsed = parseGraphArgs(commandArgs);
274
+ return parsed.error ? parsed : { command, ...parsed };
275
+ }
276
+
277
+ if (command === "doctor") {
278
+ const parsed = parseDoctorArgs(commandArgs);
279
+ return parsed.error ? parsed : { command, ...parsed };
280
+ }
281
+
282
+ return { error: `Unknown command: ${command}` };
283
+ }
284
+
285
+ function buildTemplate(title) {
286
+ return `openapi: "3.0.3"
287
+ info:
288
+ title: ${title}
289
+ version: "1.0.0"
290
+ paths:
291
+ /resources:
292
+ post:
293
+ summary: Create resource
294
+ operationId: createResource
295
+ x-flow:
296
+ version: "1.0"
297
+ id: create-resource-flow
298
+ current_state: CREATED
299
+ transitions:
300
+ - target_state: COMPLETED
301
+ condition: Resource reaches terminal condition.
302
+ trigger_type: synchronous
303
+ responses:
304
+ "201":
305
+ description: Resource created.
306
+
307
+ /resources/{id}/complete:
308
+ post:
309
+ summary: Complete resource
310
+ operationId: completeResource
311
+ x-flow:
312
+ version: "1.0"
313
+ id: complete-resource-flow
314
+ current_state: COMPLETED
315
+ responses:
316
+ "200":
317
+ description: Resource completed.
318
+ `;
319
+ }
320
+
321
+ function runInit(parsed) {
322
+ if (fs.existsSync(parsed.outputFile)) {
323
+ console.error(`ERROR: File already exists: ${parsed.outputFile}`);
324
+ return 1;
325
+ }
326
+
327
+ fs.writeFileSync(parsed.outputFile, buildTemplate(parsed.title), "utf8");
328
+ console.log(`Template created: ${parsed.outputFile}`);
329
+ console.log(`Next step: x-openapi-flow validate ${parsed.outputFile}`);
330
+ return 0;
331
+ }
332
+
333
+ function runDoctor(parsed) {
334
+ const config = loadConfig(parsed.configPath);
335
+ let hasErrors = false;
336
+
337
+ console.log("x-openapi-flow doctor");
338
+ console.log(`- Node.js: ${process.version}`);
339
+
340
+ if (config.exists) {
341
+ if (config.error) {
342
+ console.error(`- Config: FAIL (${config.path})`);
343
+ console.error(` ${config.error}`);
344
+ hasErrors = true;
345
+ } else {
346
+ console.log(`- Config: OK (${config.path})`);
347
+ }
348
+ } else {
349
+ console.log(`- Config: not found (${config.path})`);
350
+ }
351
+
352
+ const defaultApi = path.resolve(process.cwd(), "examples", "payment-api.yaml");
353
+ if (fs.existsSync(defaultApi)) {
354
+ console.log(`- Example API: found (${defaultApi})`);
355
+ } else {
356
+ console.log("- Example API: not found in current directory (this is optional)");
357
+ }
358
+
359
+ try {
360
+ if (fs.existsSync(defaultApi)) {
361
+ const api = loadApi(defaultApi);
362
+ extractFlows(api);
363
+ console.log("- Validator engine: OK");
364
+ } else {
365
+ console.log("- Validator engine: OK");
366
+ }
367
+ } catch (_err) {
368
+ console.error("- Validator engine: FAIL");
369
+ hasErrors = true;
370
+ }
371
+
372
+ return hasErrors ? 1 : 0;
373
+ }
374
+
375
+ function buildMermaidGraph(filePath) {
376
+ const api = loadApi(filePath);
377
+ const flows = extractFlows(api);
378
+ const graph = buildStateGraph(flows);
379
+ const lines = ["stateDiagram-v2"];
380
+
381
+ for (const state of graph.nodes) {
382
+ lines.push(` state ${state}`);
383
+ }
384
+
385
+ for (const [from, targets] of graph.adjacency.entries()) {
386
+ for (const to of targets) {
387
+ lines.push(` ${from} --> ${to}`);
388
+ }
389
+ }
390
+
391
+ return {
392
+ flowCount: flows.length,
393
+ nodes: [...graph.nodes],
394
+ edges: [...graph.adjacency.entries()].flatMap(([from, targets]) =>
395
+ [...targets].map((to) => ({ from, to }))
396
+ ),
397
+ mermaid: lines.join("\n"),
398
+ };
399
+ }
400
+
401
+ function runGraph(parsed) {
402
+ try {
403
+ const graphResult = buildMermaidGraph(parsed.filePath);
404
+ if (parsed.format === "json") {
405
+ console.log(JSON.stringify(graphResult, null, 2));
406
+ } else {
407
+ console.log(graphResult.mermaid);
408
+ }
409
+ return 0;
410
+ } catch (err) {
411
+ console.error(`ERROR: Could not build graph — ${err.message}`);
412
+ return 1;
413
+ }
414
+ }
415
+
416
+ function main() {
417
+ const parsed = parseArgs(process.argv);
418
+
419
+ if (parsed.help) {
420
+ printHelp();
421
+ process.exit(0);
422
+ }
423
+
424
+ if (parsed.error) {
425
+ console.error(`ERROR: ${parsed.error}`);
426
+ console.log("");
427
+ printHelp();
428
+ process.exit(1);
429
+ }
430
+
431
+ if (parsed.command === "init") {
432
+ process.exit(runInit(parsed));
433
+ }
434
+
435
+ if (parsed.command === "doctor") {
436
+ process.exit(runDoctor(parsed));
437
+ }
438
+
439
+ if (parsed.command === "graph") {
440
+ process.exit(runGraph(parsed));
441
+ }
442
+
443
+ const config = loadConfig(parsed.configPath);
444
+ if (config.error) {
445
+ console.error(`ERROR: ${config.error}`);
446
+ process.exit(1);
447
+ }
448
+
449
+ const options = {
450
+ output: parsed.format || config.data.format || "pretty",
451
+ strictQuality:
452
+ parsed.strictQuality ||
453
+ config.data.strictQuality === true,
454
+ profile: parsed.profile || config.data.profile || "strict",
455
+ };
456
+
457
+ const result = run(parsed.filePath, options);
458
+ process.exit(result.ok ? 0 : 1);
459
+ }
460
+
461
+ main();
@@ -0,0 +1,70 @@
1
+ openapi: "3.0.3"
2
+ info:
3
+ title: Non-Terminating States API
4
+ version: "1.0.0"
5
+ description: >
6
+ Example API crafted to demonstrate non_terminating_states detection.
7
+
8
+ paths:
9
+ /flows/start:
10
+ post:
11
+ summary: Start flow
12
+ operationId: startFlow
13
+ x-flow:
14
+ version: "1.0"
15
+ id: start-flow
16
+ current_state: START
17
+ transitions:
18
+ - target_state: LOOP_A
19
+ condition: Branch goes to cyclic subflow.
20
+ trigger_type: synchronous
21
+ - target_state: DONE
22
+ condition: Happy path reaches terminal state.
23
+ trigger_type: synchronous
24
+ responses:
25
+ "200":
26
+ description: Flow started.
27
+
28
+ /flows/loop-a:
29
+ post:
30
+ summary: Loop A
31
+ operationId: loopA
32
+ x-flow:
33
+ version: "1.0"
34
+ id: loop-a-flow
35
+ current_state: LOOP_A
36
+ transitions:
37
+ - target_state: LOOP_B
38
+ condition: Internal loop transition.
39
+ trigger_type: webhook
40
+ responses:
41
+ "200":
42
+ description: Loop A handled.
43
+
44
+ /flows/loop-b:
45
+ post:
46
+ summary: Loop B
47
+ operationId: loopB
48
+ x-flow:
49
+ version: "1.0"
50
+ id: loop-b-flow
51
+ current_state: LOOP_B
52
+ transitions:
53
+ - target_state: LOOP_A
54
+ condition: Cycles back to Loop A.
55
+ trigger_type: polling
56
+ responses:
57
+ "200":
58
+ description: Loop B handled.
59
+
60
+ /flows/done:
61
+ post:
62
+ summary: Done
63
+ operationId: doneFlow
64
+ x-flow:
65
+ version: "1.0"
66
+ id: done-flow
67
+ current_state: DONE
68
+ responses:
69
+ "200":
70
+ description: Terminal state.
@@ -0,0 +1,111 @@
1
+ openapi: "3.0.3"
2
+ info:
3
+ title: Order API
4
+ version: "1.0.0"
5
+ description: >
6
+ Example API demonstrating x-flow for order lifecycle orchestration.
7
+
8
+ paths:
9
+ /orders:
10
+ post:
11
+ summary: Create a new order
12
+ operationId: createOrder
13
+ x-flow:
14
+ version: "1.0"
15
+ id: create-order-flow
16
+ current_state: CREATED
17
+ description: A new order is accepted and created.
18
+ transitions:
19
+ - target_state: CONFIRMED
20
+ condition: Stock and payment checks pass.
21
+ trigger_type: synchronous
22
+ - target_state: CANCELLED
23
+ condition: Validation fails before confirmation.
24
+ trigger_type: synchronous
25
+ responses:
26
+ "201":
27
+ description: Order created successfully.
28
+
29
+ /orders/{id}/confirm:
30
+ post:
31
+ summary: Confirm an order
32
+ operationId: confirmOrder
33
+ x-flow:
34
+ version: "1.0"
35
+ id: confirm-order-flow
36
+ current_state: CONFIRMED
37
+ description: Order was confirmed and handed to fulfillment.
38
+ transitions:
39
+ - target_state: SHIPPED
40
+ condition: Warehouse dispatches package.
41
+ trigger_type: webhook
42
+ parameters:
43
+ - name: id
44
+ in: path
45
+ required: true
46
+ schema:
47
+ type: string
48
+ responses:
49
+ "200":
50
+ description: Order confirmed.
51
+
52
+ /orders/{id}/ship:
53
+ post:
54
+ summary: Mark order as shipped
55
+ operationId: shipOrder
56
+ x-flow:
57
+ version: "1.0"
58
+ id: ship-order-flow
59
+ current_state: SHIPPED
60
+ description: Package leaves warehouse.
61
+ transitions:
62
+ - target_state: DELIVERED
63
+ condition: Carrier confirms delivery.
64
+ trigger_type: webhook
65
+ parameters:
66
+ - name: id
67
+ in: path
68
+ required: true
69
+ schema:
70
+ type: string
71
+ responses:
72
+ "200":
73
+ description: Order shipped.
74
+
75
+ /orders/{id}/deliver:
76
+ post:
77
+ summary: Mark order as delivered
78
+ operationId: deliverOrder
79
+ x-flow:
80
+ version: "1.0"
81
+ id: deliver-order-flow
82
+ current_state: DELIVERED
83
+ description: Final state after customer receives package.
84
+ parameters:
85
+ - name: id
86
+ in: path
87
+ required: true
88
+ schema:
89
+ type: string
90
+ responses:
91
+ "200":
92
+ description: Order delivered.
93
+
94
+ /orders/{id}/cancel:
95
+ post:
96
+ summary: Cancel an order
97
+ operationId: cancelOrder
98
+ x-flow:
99
+ version: "1.0"
100
+ id: cancel-order-flow
101
+ current_state: CANCELLED
102
+ description: Terminal cancellation state.
103
+ parameters:
104
+ - name: id
105
+ in: path
106
+ required: true
107
+ schema:
108
+ type: string
109
+ responses:
110
+ "200":
111
+ description: Order canceled.