x-openapi-flow 1.4.4 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,6 +54,34 @@ Turn your OpenAPI spec into a single source of truth for API behavior:
54
54
 
55
55
  ## Quick Start
56
56
 
57
+ Fastest way to see value (guided scaffold):
58
+
59
+ ```bash
60
+ npx x-openapi-flow quickstart
61
+ cd x-openapi-flow-quickstart
62
+ npm install
63
+ npm start
64
+ ```
65
+
66
+ Optional runtime:
67
+
68
+ ```bash
69
+ npx x-openapi-flow quickstart --runtime fastify
70
+ ```
71
+
72
+ Then run:
73
+
74
+ ```bash
75
+ curl -s -X POST http://localhost:3110/orders
76
+ curl -i -X POST http://localhost:3110/orders/<id>/ship
77
+ ```
78
+
79
+ Expected: `409 INVALID_STATE_TRANSITION`.
80
+
81
+ ---
82
+
83
+ If you already have an OpenAPI file, use the sidecar workflow:
84
+
57
85
  Initialize flow support in your project:
58
86
 
59
87
  ```bash
@@ -76,6 +104,12 @@ This will:
76
104
 
77
105
  💡 Tip: run this in CI to enforce API workflow correctness
78
106
 
107
+ ### Less Verbose DSL for Large Flows
108
+
109
+ For larger APIs, you can define flow rules by resource (with shared transitions/defaults) and reduce duplication in sidecar files.
110
+
111
+ See: [Sidecar Contract](https://github.com/tiago-marques/x-openapi-flow/blob/main/docs/wiki/reference/Sidecar-Contract.md)
112
+
79
113
  <a id="mermaid-example"></a>
80
114
  ### Real Lifecycle Example
81
115
 
@@ -124,6 +158,147 @@ await payment.capture();
124
158
 
125
159
  > This SDK guides developers through valid transition paths, following patterns used by market leaders to ensure safe and intuitive integrations.
126
160
 
161
+ ## Runtime Enforcement (Express + Fastify)
162
+
163
+ CI validation is important, but production safety needs request-time enforcement.
164
+
165
+ `x-openapi-flow` now includes an official runtime guard for Node.js that can block invalid state transitions during request handling.
166
+
167
+ - Works with **Express** and **Fastify**
168
+ - Resolves operations by `operationId` (when available) or by method + route
169
+ - Reads current resource state using your own persistence callback
170
+ - Blocks invalid transitions with explicit `409` error payloads
171
+
172
+ Install and use directly in your API server:
173
+
174
+ ```js
175
+ const {
176
+ createExpressFlowGuard,
177
+ createFastifyFlowGuard,
178
+ } = require("x-openapi-flow/lib/runtime-guard");
179
+ ```
180
+
181
+ Express example:
182
+
183
+ ```js
184
+ const express = require("express");
185
+ const { createExpressFlowGuard } = require("x-openapi-flow/lib/runtime-guard");
186
+ const openapi = require("./openapi.flow.json");
187
+
188
+ const app = express();
189
+
190
+ app.use(
191
+ createExpressFlowGuard({
192
+ openapi,
193
+ async getCurrentState({ resourceId }) {
194
+ if (!resourceId) return null;
195
+ return paymentStore.getState(resourceId); // your DB/service lookup
196
+ },
197
+ resolveResourceId: ({ params }) => params.id || null,
198
+ })
199
+ );
200
+ ```
201
+
202
+ Fastify example:
203
+
204
+ ```js
205
+ const fastify = require("fastify")();
206
+ const { createFastifyFlowGuard } = require("x-openapi-flow/lib/runtime-guard");
207
+ const openapi = require("./openapi.flow.json");
208
+
209
+ fastify.addHook(
210
+ "preHandler",
211
+ createFastifyFlowGuard({
212
+ openapi,
213
+ async getCurrentState({ resourceId }) {
214
+ if (!resourceId) return null;
215
+ return paymentStore.getState(resourceId);
216
+ },
217
+ resolveResourceId: ({ params }) => params.id || null,
218
+ })
219
+ );
220
+ ```
221
+
222
+ Error payload for blocked transition:
223
+
224
+ ```json
225
+ {
226
+ "error": {
227
+ "code": "INVALID_STATE_TRANSITION",
228
+ "message": "Blocked invalid transition for operation 'capturePayment'. Current state 'CREATED' cannot transition to this operation.",
229
+ "operation_id": "capturePayment",
230
+ "current_state": "CREATED",
231
+ "allowed_from_states": ["AUTHORIZED"],
232
+ "resource_id": "pay_123"
233
+ }
234
+ }
235
+ ```
236
+
237
+ More details: [Runtime Guard](https://github.com/tiago-marques/x-openapi-flow/blob/main/docs/wiki/reference/Runtime-Guard.md)
238
+
239
+ ### 5-Minute Demo: Real Runtime Block (E-commerce Orders)
240
+
241
+ Want to see the value immediately? Use the official minimal demo:
242
+
243
+ - [example/runtime-guard/minimal-order/README.md](https://github.com/tiago-marques/x-openapi-flow/blob/main/example/runtime-guard/minimal-order/README.md)
244
+
245
+ Run in under 5 minutes:
246
+
247
+ ```bash
248
+ cd example/runtime-guard/minimal-order
249
+ npm install
250
+ npm start
251
+ ```
252
+
253
+ Create an order, then try to ship before payment (must return `409 INVALID_STATE_TRANSITION`):
254
+
255
+ ```bash
256
+ curl -s -X POST http://localhost:3110/orders
257
+ curl -i -X POST http://localhost:3110/orders/<id>/ship
258
+ ```
259
+
260
+ HTTPie equivalent:
261
+
262
+ ```bash
263
+ http POST :3110/orders
264
+ http -v POST :3110/orders/<id>/ship
265
+ ```
266
+
267
+ ## Programmatic State Machine Engine
268
+
269
+ Use a reusable deterministic engine independently of CLI and OpenAPI parsing:
270
+
271
+ ```js
272
+ const { createStateMachineEngine } = require("x-openapi-flow/lib/state-machine-engine");
273
+
274
+ const engine = createStateMachineEngine({
275
+ transitions: [
276
+ { from: "CREATED", action: "confirm", to: "CONFIRMED" },
277
+ { from: "CONFIRMED", action: "ship", to: "SHIPPED" },
278
+ ],
279
+ });
280
+
281
+ engine.canTransition("CREATED", "confirm");
282
+ engine.getNextState("CREATED", "confirm");
283
+ engine.validateFlow({ startState: "CREATED", actions: ["confirm", "ship"] });
284
+ ```
285
+
286
+ More details: [State Machine Engine](https://github.com/tiago-marques/x-openapi-flow/blob/main/docs/wiki/reference/State-Machine-Engine.md)
287
+
288
+ ### OpenAPI to Engine Adapter
289
+
290
+ Convert `x-openapi-flow` metadata to a pure engine definition:
291
+
292
+ ```js
293
+ const { createStateMachineAdapterModel } = require("x-openapi-flow/lib/openapi-state-machine-adapter");
294
+ const { createStateMachineEngine } = require("x-openapi-flow/lib/state-machine-engine");
295
+
296
+ const model = createStateMachineAdapterModel({ openapiPath: "./openapi.flow.yaml" });
297
+ const engine = createStateMachineEngine(model.definition);
298
+ ```
299
+
300
+ More details: [OpenAPI State Machine Adapter](https://github.com/tiago-marques/x-openapi-flow/blob/main/docs/wiki/reference/OpenAPI-State-Machine-Adapter.md)
301
+
127
302
  ## Who Benefits Most
128
303
 
129
304
  x-openapi-flow is ideal for teams and organizations that want **clear, enforceable API workflows**:
@@ -227,6 +402,8 @@ npx x-openapi-flow version # show version
227
402
  npx x-openapi-flow doctor [--config path] # check setup and config
228
403
 
229
404
  npx x-openapi-flow completion [bash|zsh] # enable shell autocompletion
405
+
406
+ npx x-openapi-flow quickstart [--dir path] [--runtime express|fastify] [--force] # scaffold runnable onboarding project
230
407
  ```
231
408
 
232
409
  ### Workflow Management
@@ -239,7 +416,7 @@ npx x-openapi-flow init [--flows path] [--force] [--dry-run]
239
416
  npx x-openapi-flow apply [openapi-file] [--flows path] [--out path]
240
417
 
241
418
  # validate transitions
242
- npx x-openapi-flow validate <openapi-file> [--profile core|relaxed|strict] [--strict-quality]
419
+ npx x-openapi-flow validate <openapi-file> [--profile core|relaxed|strict] [--strict-quality] [--semantic]
243
420
  ```
244
421
 
245
422
  ### Visualization & Documentation
@@ -262,6 +439,16 @@ npx x-openapi-flow export-doc-flows [openapi-file] [--output path] [--format mar
262
439
  npx x-openapi-flow generate-sdk [openapi-file] --lang typescript [--output path]
263
440
  ```
264
441
 
442
+ ### Test Generation
443
+
444
+ ```bash
445
+ # generate executable flow tests (happy path + invalid transitions)
446
+ npx x-openapi-flow generate-flow-tests [openapi-file] [--format jest|vitest|postman] [--output path]
447
+
448
+ # postman/newman-oriented collection with flow scripts
449
+ npx x-openapi-flow generate-flow-tests [openapi-file] --format postman [--output path] [--with-scripts]
450
+ ```
451
+
265
452
  Full details:
266
453
 
267
454
  - [CLI-Reference.md](https://github.com/tiago-marques/x-openapi-flow/blob/main/docs/wiki/reference/CLI-Reference.md)
@@ -6,10 +6,12 @@ const { exportDocFlows } = require("./docs/doc-adapter");
6
6
  const { generatePostmanCollection } = require("./collections/postman-adapter");
7
7
  const { generateInsomniaWorkspace } = require("./collections/insomnia-adapter");
8
8
  const { generateRedocPackage } = require("./ui/redoc-adapter");
9
+ const { generateFlowTests } = require("./tests/flow-test-adapter");
9
10
 
10
11
  module.exports = {
11
12
  exportDocFlows,
12
13
  generatePostmanCollection,
13
14
  generateInsomniaWorkspace,
14
15
  generateRedocPackage,
16
+ generateFlowTests,
15
17
  };
@@ -0,0 +1,254 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const { loadApi } = require("../../lib/validator");
6
+ const { createStateMachineAdapterModel } = require("../../lib/openapi-state-machine-adapter");
7
+ const { createStateMachineEngine } = require("../../lib/state-machine-engine");
8
+ const { generatePostmanCollection } = require("../collections/postman-adapter");
9
+
10
+ function getFormat(options) {
11
+ const format = String(options.format || "jest").toLowerCase();
12
+ if (!["jest", "vitest", "postman"].includes(format)) {
13
+ throw new Error(`Unsupported test format '${format}'. Use 'jest', 'vitest', or 'postman'.`);
14
+ }
15
+ return format;
16
+ }
17
+
18
+ function getDefaultOutputPath(format) {
19
+ if (format === "postman") {
20
+ return path.resolve(process.cwd(), "x-openapi-flow.flow-tests.postman_collection.json");
21
+ }
22
+
23
+ if (format === "vitest") {
24
+ return path.resolve(process.cwd(), "x-openapi-flow.flow.generated.vitest.test.js");
25
+ }
26
+
27
+ return path.resolve(process.cwd(), "x-openapi-flow.flow.generated.jest.test.js");
28
+ }
29
+
30
+ function buildHappyPaths(engine) {
31
+ const transitions = engine.getTransitions();
32
+ const outgoing = new Map();
33
+ const indegree = new Map();
34
+ const states = engine.getStates();
35
+
36
+ for (const state of states) {
37
+ outgoing.set(state, []);
38
+ indegree.set(state, 0);
39
+ }
40
+
41
+ for (const transition of transitions) {
42
+ if (!outgoing.has(transition.from)) {
43
+ outgoing.set(transition.from, []);
44
+ }
45
+ outgoing.get(transition.from).push(transition);
46
+ indegree.set(transition.to, (indegree.get(transition.to) || 0) + 1);
47
+ }
48
+
49
+ for (const transitionList of outgoing.values()) {
50
+ transitionList.sort((left, right) => left.action.localeCompare(right.action));
51
+ }
52
+
53
+ const starts = states.filter((state) => (indegree.get(state) || 0) === 0);
54
+ const roots = starts.length > 0 ? starts : states;
55
+ const maxDepth = Math.max(3, states.length + 2);
56
+ const maxPaths = 50;
57
+ const paths = [];
58
+
59
+ function walk(currentState, actions, visitedKeys, depth) {
60
+ if (paths.length >= maxPaths) {
61
+ return;
62
+ }
63
+
64
+ const nextTransitions = outgoing.get(currentState) || [];
65
+ if (nextTransitions.length === 0 || depth >= maxDepth) {
66
+ if (actions.length > 0) {
67
+ paths.push({
68
+ startState: actions[0].from,
69
+ actions: actions.map((entry) => entry.action),
70
+ finalState: currentState,
71
+ });
72
+ }
73
+ return;
74
+ }
75
+
76
+ for (const transition of nextTransitions) {
77
+ const visitKey = `${transition.from}::${transition.action}::${transition.to}`;
78
+ if (visitedKeys.has(visitKey)) {
79
+ continue;
80
+ }
81
+
82
+ const nextVisited = new Set(visitedKeys);
83
+ nextVisited.add(visitKey);
84
+
85
+ walk(
86
+ transition.to,
87
+ actions.concat([{ from: transition.from, action: transition.action }]),
88
+ nextVisited,
89
+ depth + 1
90
+ );
91
+ }
92
+ }
93
+
94
+ for (const root of roots) {
95
+ walk(root, [], new Set(), 0);
96
+ }
97
+
98
+ const dedup = new Map();
99
+ for (const entry of paths) {
100
+ const key = `${entry.startState}::${entry.actions.join(",")}`;
101
+ if (!dedup.has(key)) {
102
+ dedup.set(key, entry);
103
+ }
104
+ }
105
+
106
+ return [...dedup.values()];
107
+ }
108
+
109
+ function buildInvalidCases(engine) {
110
+ const transitions = engine.getTransitions();
111
+ const states = engine.getStates();
112
+ const knownActions = [...new Set(transitions.map((transition) => transition.action))].sort();
113
+ const cases = [];
114
+
115
+ for (const state of states) {
116
+ const available = engine.getAvailableActions(state);
117
+ const disallowedAction = knownActions.find((action) => !available.includes(action));
118
+
119
+ if (disallowedAction) {
120
+ cases.push({
121
+ state,
122
+ action: disallowedAction,
123
+ availableActions: available,
124
+ });
125
+ continue;
126
+ }
127
+
128
+ cases.push({
129
+ state,
130
+ action: "__invalid_action__",
131
+ availableActions: available,
132
+ });
133
+ }
134
+
135
+ return cases;
136
+ }
137
+
138
+ function buildJestOrVitestContent({ format, sourcePath, definition, happyPaths, invalidCases }) {
139
+ const frameworkPrelude = format === "vitest"
140
+ ? "const { describe, test, expect } = require(\"vitest\");\n"
141
+ : "";
142
+
143
+ const title = format === "vitest" ? "Vitest" : "Jest";
144
+
145
+ return `"use strict";
146
+
147
+ ${frameworkPrelude}const { createStateMachineEngine } = require("x-openapi-flow/lib/state-machine-engine");
148
+
149
+ const definition = ${JSON.stringify(definition, null, 2)};
150
+ const happyPaths = ${JSON.stringify(happyPaths, null, 2)};
151
+ const invalidCases = ${JSON.stringify(invalidCases, null, 2)};
152
+
153
+ describe("x-openapi-flow generated tests (${title})", () => {
154
+ const engine = createStateMachineEngine(definition);
155
+
156
+ test("has transitions in definition", () => {
157
+ expect(Array.isArray(definition.transitions)).toBe(true);
158
+ expect(definition.transitions.length).toBeGreaterThan(0);
159
+ });
160
+
161
+ describe("happy paths", () => {
162
+ for (const pathCase of happyPaths) {
163
+ test(
164
+ \`valid flow: \${pathCase.startState} -> \${pathCase.actions.join(" -> ")}\`,
165
+ () => {
166
+ const result = engine.validateFlow({
167
+ startState: pathCase.startState,
168
+ actions: pathCase.actions,
169
+ });
170
+
171
+ expect(result.ok).toBe(true);
172
+ expect(result.finalState).toBe(pathCase.finalState);
173
+ }
174
+ );
175
+ }
176
+ });
177
+
178
+ describe("invalid transitions", () => {
179
+ for (const invalidCase of invalidCases) {
180
+ test(
181
+ \`blocks invalid action \${invalidCase.action} from state \${invalidCase.state}\`,
182
+ () => {
183
+ expect(engine.canTransition(invalidCase.state, invalidCase.action)).toBe(false);
184
+
185
+ const result = engine.validateFlow({
186
+ startState: invalidCase.state,
187
+ actions: [invalidCase.action],
188
+ });
189
+
190
+ expect(result.ok).toBe(false);
191
+ expect(result.error.code).toBe("INVALID_TRANSITION");
192
+ expect(Array.isArray(result.error.availableActions)).toBe(true);
193
+ }
194
+ );
195
+ }
196
+ });
197
+ });
198
+
199
+ // Generated from: ${sourcePath}
200
+ `;
201
+ }
202
+
203
+ function generateFlowTests(options) {
204
+ const format = getFormat(options || {});
205
+ const apiPath = path.resolve(options.apiPath);
206
+ const outputPath = path.resolve(options.outputPath || getDefaultOutputPath(format));
207
+
208
+ if (format === "postman") {
209
+ const postman = generatePostmanCollection({
210
+ apiPath,
211
+ outputPath,
212
+ withScripts: options.withScripts !== false,
213
+ });
214
+
215
+ return {
216
+ format,
217
+ outputPath: postman.outputPath,
218
+ flowCount: postman.flowCount,
219
+ happyPathTests: null,
220
+ invalidCaseTests: null,
221
+ withScripts: postman.withScripts,
222
+ };
223
+ }
224
+
225
+ const api = loadApi(apiPath);
226
+ const model = createStateMachineAdapterModel({ openapi: api });
227
+ const engine = createStateMachineEngine(model.definition);
228
+
229
+ const happyPaths = buildHappyPaths(engine);
230
+ const invalidCases = buildInvalidCases(engine);
231
+ const content = buildJestOrVitestContent({
232
+ format,
233
+ sourcePath: apiPath,
234
+ definition: model.definition,
235
+ happyPaths,
236
+ invalidCases,
237
+ });
238
+
239
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
240
+ fs.writeFileSync(outputPath, content, "utf8");
241
+
242
+ return {
243
+ format,
244
+ outputPath,
245
+ flowCount: model.definition.transitions.length,
246
+ happyPathTests: happyPaths.length,
247
+ invalidCaseTests: invalidCases.length,
248
+ withScripts: null,
249
+ };
250
+ }
251
+
252
+ module.exports = {
253
+ generateFlowTests,
254
+ };