x-openapi-flow 1.4.4 → 1.5.1

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
@@ -1,5 +1,9 @@
1
1
  <!-- Auto-generated from /README.md via scripts/sync-package-readme.js. Do not edit directly. -->
2
2
 
3
+ # OpenAPI describes APIs. x-openapi-flow turns them into executable workflows — for developers and AI agents.
4
+
5
+ ## Define your API workflows in openapi.x.json and execute them without writing custom clients or orchestration logic.
6
+
3
7
  ![x-openapi-flow logo](https://raw.githubusercontent.com/tiago-marques/x-openapi-flow/main/docs/assets/x-openapi-flow-logo.svg)
4
8
 
5
9
  [![npm version](https://img.shields.io/npm/v/x-openapi-flow?label=npm%20version)](https://www.npmjs.com/package/x-openapi-flow)
@@ -11,21 +15,69 @@
11
15
  [![open issues](https://img.shields.io/github/issues/tiago-marques/x-openapi-flow)](https://github.com/tiago-marques/x-openapi-flow/issues)
12
16
  [![last commit](https://img.shields.io/github/last-commit/tiago-marques/x-openapi-flow)](https://github.com/tiago-marques/x-openapi-flow/commits/main)
13
17
  ![copilot ready](https://img.shields.io/badge/Copilot-Ready-00BFA5?logo=githubcopilot&logoColor=white)
14
- > 🚀 1,400+ downloads in the first 3 weeks!
15
-
16
- # OpenAPI describes APIs. x-openapi-flow describes their workflows — for developers and AI.
18
+ > 🚀 2,100+ downloads in the first 3 weeks!
17
19
 
18
- ![x-openapi-flow in action](https://raw.githubusercontent.com/tiago-marques/x-openapi-flow/main/docs/assets/ezgif.com-animated-gif-maker.gif)
20
+ ## ⚡ Get started in seconds
21
+ > npx x-openapi-flow init
19
22
 
23
+ ### This generates an openapi.x.json file where you can declaratively define how your API should be executed — not just described.
20
24
 
21
25
  > See your API lifecycle come alive from your OpenAPI spec, with one simple command
22
26
 
23
27
  > Validate, document, and generate flow-aware SDKs automatically.
24
28
 
29
+ ![x-openapi-flow in action](https://raw.githubusercontent.com/tiago-marques/x-openapi-flow/main/docs/assets/ezgif.com-animated-gif-maker.gif)
30
+
31
+ ## What is this?
32
+
33
+ ### x-openapi-flow extends your OpenAPI specification with a workflow layer.
34
+
35
+ > openapi.json → describes your API
36
+ > openapi.x.json → describes how to use it (flows)
37
+
38
+ ### Instead of writing imperative code to orchestrate API calls, you define workflows declaratively and run them anywhere.
39
+
25
40
  `x-openapi-flow` adds a **declarative state machine** to your OpenAPI spec.
26
41
 
27
42
  Model resource lifecycles, enforce valid transitions, and generate flow-aware artifacts for documentation, SDKs, and automation.
28
43
 
44
+ ## 🚀 Example
45
+
46
+ > Define stateful workflows and lifecycle transitions directly inside your OpenAPI operations:
47
+
48
+ ```json
49
+ {
50
+ "operationId": "createOrder",
51
+ "x-openapi-flow": {
52
+ "id": "create-order",
53
+ "current_state": "created",
54
+ "description": "Creates an order and starts the lifecycle",
55
+ "transitions": [
56
+ {
57
+ "trigger_type": "synchronous",
58
+ "condition": "Payment is confirmed",
59
+ "target_state": "paid",
60
+ "next_operation_id": "payOrder",
61
+ "prerequisite_operation_ids": ["createOrder"],
62
+ "propagated_field_refs": [
63
+ "createOrder:response.201.body.order_id"
64
+ ]
65
+ }
66
+ ]
67
+ }
68
+ }
69
+ ```
70
+
71
+ This flow defines an order lifecycle directly inside your OpenAPI:
72
+
73
+ * Starts in the `created` state
74
+ * Transitions to `paid` when payment is confirmed
75
+ * Supports both synchronous and polling-based transitions
76
+ * Propagates data between operations automatically
77
+
78
+ Instead of manually orchestrating API calls, the workflow is fully described alongside your API specification.
79
+
80
+
29
81
  ## Why This Exists
30
82
 
31
83
  Building APIs is cheap. Building **complex, multi-step APIs that teams actually use correctly** is hard.
@@ -52,7 +104,37 @@ Turn your OpenAPI spec into a single source of truth for API behavior:
52
104
  - Export [Postman](#postman-demo) and [Insomnia](#insomnia-demo) collections organized by lifecycle
53
105
  - Create [AI-ready API contracts](https://github.com/tiago-marques/x-openapi-flow/blob/main/docs/wiki/engineering/AI-Sidecar-Authoring.md) for agentic integrations
54
106
 
55
- ## Quick Start
107
+ ## Quick Start (without OpenAPI file)
108
+
109
+ Fastest way to see value (guided scaffold):
110
+
111
+ ```bash
112
+ npx x-openapi-flow quickstart
113
+ cd x-openapi-flow-quickstart
114
+ npm install
115
+ npm start
116
+ ```
117
+
118
+ Optional runtime:
119
+
120
+ ```bash
121
+ npx x-openapi-flow quickstart --runtime fastify
122
+ ```
123
+
124
+ Then run:
125
+
126
+ ```bash
127
+ curl -s -X POST http://localhost:3110/orders
128
+ curl -i -X POST http://localhost:3110/orders/<id>/ship
129
+ ```
130
+
131
+ Expected: `409 INVALID_STATE_TRANSITION`.
132
+
133
+ ---
134
+ ### If you already have an OpenAPI file, use the sidecar workflow:
135
+
136
+
137
+
56
138
 
57
139
  Initialize flow support in your project:
58
140
 
@@ -76,6 +158,12 @@ This will:
76
158
 
77
159
  💡 Tip: run this in CI to enforce API workflow correctness
78
160
 
161
+ ### Less Verbose DSL for Large Flows
162
+
163
+ For larger APIs, you can define flow rules by resource (with shared transitions/defaults) and reduce duplication in sidecar files.
164
+
165
+ See: [Sidecar Contract](https://github.com/tiago-marques/x-openapi-flow/blob/main/docs/wiki/reference/Sidecar-Contract.md)
166
+
79
167
  <a id="mermaid-example"></a>
80
168
  ### Real Lifecycle Example
81
169
 
@@ -124,6 +212,147 @@ await payment.capture();
124
212
 
125
213
  > This SDK guides developers through valid transition paths, following patterns used by market leaders to ensure safe and intuitive integrations.
126
214
 
215
+ ## Runtime Enforcement (Express + Fastify)
216
+
217
+ CI validation is important, but production safety needs request-time enforcement.
218
+
219
+ `x-openapi-flow` now includes an official runtime guard for Node.js that can block invalid state transitions during request handling.
220
+
221
+ - Works with **Express** and **Fastify**
222
+ - Resolves operations by `operationId` (when available) or by method + route
223
+ - Reads current resource state using your own persistence callback
224
+ - Blocks invalid transitions with explicit `409` error payloads
225
+
226
+ Install and use directly in your API server:
227
+
228
+ ```js
229
+ const {
230
+ createExpressFlowGuard,
231
+ createFastifyFlowGuard,
232
+ } = require("x-openapi-flow/lib/runtime-guard");
233
+ ```
234
+
235
+ Express example:
236
+
237
+ ```js
238
+ const express = require("express");
239
+ const { createExpressFlowGuard } = require("x-openapi-flow/lib/runtime-guard");
240
+ const openapi = require("./openapi.flow.json");
241
+
242
+ const app = express();
243
+
244
+ app.use(
245
+ createExpressFlowGuard({
246
+ openapi,
247
+ async getCurrentState({ resourceId }) {
248
+ if (!resourceId) return null;
249
+ return paymentStore.getState(resourceId); // your DB/service lookup
250
+ },
251
+ resolveResourceId: ({ params }) => params.id || null,
252
+ })
253
+ );
254
+ ```
255
+
256
+ Fastify example:
257
+
258
+ ```js
259
+ const fastify = require("fastify")();
260
+ const { createFastifyFlowGuard } = require("x-openapi-flow/lib/runtime-guard");
261
+ const openapi = require("./openapi.flow.json");
262
+
263
+ fastify.addHook(
264
+ "preHandler",
265
+ createFastifyFlowGuard({
266
+ openapi,
267
+ async getCurrentState({ resourceId }) {
268
+ if (!resourceId) return null;
269
+ return paymentStore.getState(resourceId);
270
+ },
271
+ resolveResourceId: ({ params }) => params.id || null,
272
+ })
273
+ );
274
+ ```
275
+
276
+ Error payload for blocked transition:
277
+
278
+ ```json
279
+ {
280
+ "error": {
281
+ "code": "INVALID_STATE_TRANSITION",
282
+ "message": "Blocked invalid transition for operation 'capturePayment'. Current state 'CREATED' cannot transition to this operation.",
283
+ "operation_id": "capturePayment",
284
+ "current_state": "CREATED",
285
+ "allowed_from_states": ["AUTHORIZED"],
286
+ "resource_id": "pay_123"
287
+ }
288
+ }
289
+ ```
290
+
291
+ More details: [Runtime Guard](https://github.com/tiago-marques/x-openapi-flow/blob/main/docs/wiki/reference/Runtime-Guard.md)
292
+
293
+ ### 5-Minute Demo: Real Runtime Block (E-commerce Orders)
294
+
295
+ Want to see the value immediately? Use the official minimal demo:
296
+
297
+ - [example/runtime-guard/minimal-order/README.md](https://github.com/tiago-marques/x-openapi-flow/blob/main/example/runtime-guard/minimal-order/README.md)
298
+
299
+ Run in under 5 minutes:
300
+
301
+ ```bash
302
+ cd example/runtime-guard/minimal-order
303
+ npm install
304
+ npm start
305
+ ```
306
+
307
+ Create an order, then try to ship before payment (must return `409 INVALID_STATE_TRANSITION`):
308
+
309
+ ```bash
310
+ curl -s -X POST http://localhost:3110/orders
311
+ curl -i -X POST http://localhost:3110/orders/<id>/ship
312
+ ```
313
+
314
+ HTTPie equivalent:
315
+
316
+ ```bash
317
+ http POST :3110/orders
318
+ http -v POST :3110/orders/<id>/ship
319
+ ```
320
+
321
+ ## Programmatic State Machine Engine
322
+
323
+ Use a reusable deterministic engine independently of CLI and OpenAPI parsing:
324
+
325
+ ```js
326
+ const { createStateMachineEngine } = require("x-openapi-flow/lib/state-machine-engine");
327
+
328
+ const engine = createStateMachineEngine({
329
+ transitions: [
330
+ { from: "CREATED", action: "confirm", to: "CONFIRMED" },
331
+ { from: "CONFIRMED", action: "ship", to: "SHIPPED" },
332
+ ],
333
+ });
334
+
335
+ engine.canTransition("CREATED", "confirm");
336
+ engine.getNextState("CREATED", "confirm");
337
+ engine.validateFlow({ startState: "CREATED", actions: ["confirm", "ship"] });
338
+ ```
339
+
340
+ More details: [State Machine Engine](https://github.com/tiago-marques/x-openapi-flow/blob/main/docs/wiki/reference/State-Machine-Engine.md)
341
+
342
+ ### OpenAPI to Engine Adapter
343
+
344
+ Convert `x-openapi-flow` metadata to a pure engine definition:
345
+
346
+ ```js
347
+ const { createStateMachineAdapterModel } = require("x-openapi-flow/lib/openapi-state-machine-adapter");
348
+ const { createStateMachineEngine } = require("x-openapi-flow/lib/state-machine-engine");
349
+
350
+ const model = createStateMachineAdapterModel({ openapiPath: "./openapi.flow.yaml" });
351
+ const engine = createStateMachineEngine(model.definition);
352
+ ```
353
+
354
+ More details: [OpenAPI State Machine Adapter](https://github.com/tiago-marques/x-openapi-flow/blob/main/docs/wiki/reference/OpenAPI-State-Machine-Adapter.md)
355
+
127
356
  ## Who Benefits Most
128
357
 
129
358
  x-openapi-flow is ideal for teams and organizations that want **clear, enforceable API workflows**:
@@ -227,6 +456,8 @@ npx x-openapi-flow version # show version
227
456
  npx x-openapi-flow doctor [--config path] # check setup and config
228
457
 
229
458
  npx x-openapi-flow completion [bash|zsh] # enable shell autocompletion
459
+
460
+ npx x-openapi-flow quickstart [--dir path] [--runtime express|fastify] [--force] # scaffold runnable onboarding project
230
461
  ```
231
462
 
232
463
  ### Workflow Management
@@ -239,7 +470,7 @@ npx x-openapi-flow init [--flows path] [--force] [--dry-run]
239
470
  npx x-openapi-flow apply [openapi-file] [--flows path] [--out path]
240
471
 
241
472
  # validate transitions
242
- npx x-openapi-flow validate <openapi-file> [--profile core|relaxed|strict] [--strict-quality]
473
+ npx x-openapi-flow validate <openapi-file> [--profile core|relaxed|strict] [--strict-quality] [--semantic]
243
474
  ```
244
475
 
245
476
  ### Visualization & Documentation
@@ -262,6 +493,16 @@ npx x-openapi-flow export-doc-flows [openapi-file] [--output path] [--format mar
262
493
  npx x-openapi-flow generate-sdk [openapi-file] --lang typescript [--output path]
263
494
  ```
264
495
 
496
+ ### Test Generation
497
+
498
+ ```bash
499
+ # generate executable flow tests (happy path + invalid transitions)
500
+ npx x-openapi-flow generate-flow-tests [openapi-file] [--format jest|vitest|postman] [--output path]
501
+
502
+ # postman/newman-oriented collection with flow scripts
503
+ npx x-openapi-flow generate-flow-tests [openapi-file] --format postman [--output path] [--with-scripts]
504
+ ```
505
+
265
506
  Full details:
266
507
 
267
508
  - [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
+ };