x-openapi-flow 1.3.0 → 1.3.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 +108 -3
- package/adapters/collections/insomnia-adapter.js +73 -0
- package/adapters/collections/postman-adapter.js +145 -0
- package/adapters/docs/doc-adapter.js +119 -0
- package/adapters/flow-output-adapters.js +15 -0
- package/adapters/shared/helpers.js +87 -0
- package/adapters/ui/redoc/x-openapi-flow-redoc-plugin.js +127 -0
- package/adapters/ui/redoc-adapter.js +75 -0
- package/bin/x-openapi-flow.js +766 -0
- package/lib/sdk-generator.js +673 -0
- package/package.json +9 -4
- package/templates/go/README.md +3 -0
- package/templates/kotlin/README.md +3 -0
- package/templates/python/README.md +3 -0
- package/templates/typescript/flow-helpers.hbs +26 -0
- package/templates/typescript/http-client.hbs +37 -0
- package/templates/typescript/index.hbs +16 -0
- package/templates/typescript/resource.hbs +24 -0
- package/examples/swagger-ui/index.html +0 -33
- /package/{lib → adapters/ui}/swagger-ui/x-openapi-flow-plugin.js +0 -0
package/README.md
CHANGED
|
@@ -58,10 +58,115 @@ npx x-openapi-flow init [--flows path] [--force] [--dry-run]
|
|
|
58
58
|
npx x-openapi-flow apply [openapi-file] [--flows path] [--out path]
|
|
59
59
|
npx x-openapi-flow diff [openapi-file] [--flows path] [--format pretty|json]
|
|
60
60
|
npx x-openapi-flow lint [openapi-file] [--format pretty|json] [--config path]
|
|
61
|
+
npx x-openapi-flow analyze [openapi-file] [--format pretty|json] [--out path] [--merge] [--flows path]
|
|
62
|
+
npx x-openapi-flow generate-sdk [openapi-file] --lang typescript [--output path]
|
|
63
|
+
npx x-openapi-flow export-doc-flows [openapi-file] [--output path] [--format markdown|json]
|
|
64
|
+
npx x-openapi-flow generate-postman [openapi-file] [--output path] [--with-scripts]
|
|
65
|
+
npx x-openapi-flow generate-insomnia [openapi-file] [--output path]
|
|
66
|
+
npx x-openapi-flow generate-redoc [openapi-file] [--output path]
|
|
61
67
|
npx x-openapi-flow graph <openapi-file> [--format mermaid|json]
|
|
62
68
|
npx x-openapi-flow doctor [--config path]
|
|
63
69
|
```
|
|
64
70
|
|
|
71
|
+
## Output Adapters
|
|
72
|
+
|
|
73
|
+
`x-openapi-flow` now supports modular output adapters that reuse the same internal flow graph:
|
|
74
|
+
|
|
75
|
+
- OpenAPI + `x-openapi-flow` -> parser -> graph builder -> adapters
|
|
76
|
+
- Adapters: docs (`export-doc-flows`), SDK (`generate-sdk`), Postman (`generate-postman`), Insomnia (`generate-insomnia`)
|
|
77
|
+
and Redoc package (`generate-redoc`)
|
|
78
|
+
|
|
79
|
+
### Redoc/Docs Adapter (`export-doc-flows`)
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx x-openapi-flow export-doc-flows openapi.yaml --output ./docs/api-flows.md
|
|
83
|
+
npx x-openapi-flow export-doc-flows openapi.yaml --format json --output ./docs/api-flows.json
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Generates a lifecycle page (or JSON model) with:
|
|
87
|
+
|
|
88
|
+
- Flow/Lifecycle panel per resource
|
|
89
|
+
- Mermaid diagram per resource
|
|
90
|
+
- Current state, prerequisites (`prerequisite_operation_ids`), next operations (`next_operation_id`)
|
|
91
|
+
|
|
92
|
+
### Redoc Package Adapter (`generate-redoc`)
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx x-openapi-flow generate-redoc openapi.yaml --output ./redoc-flow
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Generates a ready-to-open Redoc bundle with:
|
|
99
|
+
|
|
100
|
+
- `index.html` (Redoc + Flow/Lifecycle panel)
|
|
101
|
+
- `x-openapi-flow-redoc-plugin.js` (DOM enhancer)
|
|
102
|
+
- `flow-model.json` (flow graph model)
|
|
103
|
+
- copied OpenAPI spec (`openapi.yaml`/`openapi.json`)
|
|
104
|
+
|
|
105
|
+
### Postman Adapter (`generate-postman`)
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
npx x-openapi-flow generate-postman openapi.yaml --output ./x-openapi-flow.postman_collection.json --with-scripts
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Generates lifecycle-oriented folders/journeys and optional scripts for:
|
|
112
|
+
|
|
113
|
+
- prerequisite enforcement before request execution
|
|
114
|
+
- propagated operation tracking and ID persistence in collection variables
|
|
115
|
+
|
|
116
|
+
### Insomnia Adapter (`generate-insomnia`)
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npx x-openapi-flow generate-insomnia openapi.yaml --output ./x-openapi-flow.insomnia.json
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Generates an Insomnia export organized by resource flow groups and ordered requests.
|
|
123
|
+
|
|
124
|
+
## SDK Generator (`generate-sdk`)
|
|
125
|
+
|
|
126
|
+
Generate a flow-aware SDK from OpenAPI + `x-openapi-flow` metadata.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npx x-openapi-flow generate-sdk openapi.yaml --lang typescript --output ./sdk
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
MVP output (TypeScript):
|
|
133
|
+
|
|
134
|
+
- `src/resources/<Resource>.ts`: resource client + state classes (`PaymentAuthorized`, `PaymentCaptured`, etc.)
|
|
135
|
+
- `src/index.ts`: root `FlowApiClient`
|
|
136
|
+
- `src/http-client.ts`: pluggable HTTP client interface and fetch implementation
|
|
137
|
+
- `src/flow-helpers.ts`: `runFlow("authorize -> capture")`
|
|
138
|
+
- `flow-model.json`: intermediate model `{ resource, operations, prerequisites, nextOperations, states }`
|
|
139
|
+
|
|
140
|
+
SDK layers (resource-centric):
|
|
141
|
+
|
|
142
|
+
- Collection/service layer: `api.payments.create()`, `api.payments.retrieve(id)`, `api.payments.list()`
|
|
143
|
+
- Resource instance/state layer: objects expose valid lifecycle transitions (`payment.capture()`, etc.)
|
|
144
|
+
- Optional lifecycle helper methods at service level (`api.payments.capture(id, params, { autoPrerequisites: true })`)
|
|
145
|
+
|
|
146
|
+
Pipeline used by the generator:
|
|
147
|
+
|
|
148
|
+
- OpenAPI -> parser -> flow graph -> state machine -> templates -> SDK
|
|
149
|
+
- Reuses lifecycle graph logic from the validator to stay consistent with `validate`, `graph`, and `diff`
|
|
150
|
+
- Transition ordering uses `next_operation_id`, `prerequisite_operation_ids`, and state transitions from `x-openapi-flow`
|
|
151
|
+
|
|
152
|
+
## Flow Analyzer (`analyze`)
|
|
153
|
+
|
|
154
|
+
Use `analyze` to bootstrap a sidecar from OpenAPI paths/operation names.
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npx x-openapi-flow analyze openapi.yaml --out openapi.x.yaml
|
|
158
|
+
npx x-openapi-flow analyze openapi.yaml --format json
|
|
159
|
+
npx x-openapi-flow analyze openapi.yaml --merge --flows openapi.x.yaml
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Notes:
|
|
163
|
+
|
|
164
|
+
- The output is heuristic and intended as a starting point.
|
|
165
|
+
- Inferred states/transitions should be reviewed and adjusted by API/domain owners.
|
|
166
|
+
- Default output format is `pretty`; without `--out`, the suggested sidecar is printed to stdout.
|
|
167
|
+
- `--merge` merges inferred data into an existing sidecar (default path or `--flows`) while preserving existing operation fields.
|
|
168
|
+
- In `json`, inferred transition confidence is available in `analysis.transitionConfidence`.
|
|
169
|
+
|
|
65
170
|
`diff` now reports field-level changes for operations that already exist in the sidecar.
|
|
66
171
|
In `pretty` format, this appears under `Changed details` with changed paths per operation (for example, `current_state` or `transitions[0].target_state`).
|
|
67
172
|
In `json` format, this appears in `diff.changedOperationDetails`:
|
|
@@ -299,9 +404,9 @@ Field reference format:
|
|
|
299
404
|
|
|
300
405
|
### Swagger UI
|
|
301
406
|
|
|
302
|
-
-
|
|
303
|
-
- For UI interpretation of `x-openapi-flow`, use `showExtensions: true` with the plugin at `
|
|
304
|
-
- A ready HTML example is available at
|
|
407
|
+
- UI plugin behavior is covered by tests in `tests/plugins/plugin-ui.test.js`.
|
|
408
|
+
- For UI interpretation of `x-openapi-flow`, use `showExtensions: true` with the plugin at `adapters/ui/swagger-ui/x-openapi-flow-plugin.js`.
|
|
409
|
+
- A ready HTML example is available at `../example-project/examples/swagger-ui/index.html`.
|
|
305
410
|
- The plugin renders a global **Flow Overview** (Mermaid image) near the top of the docs, plus operation-level flow cards.
|
|
306
411
|
|
|
307
412
|

|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { loadApi } = require("../../lib/validator");
|
|
6
|
+
const { buildIntermediateModel } = require("../../lib/sdk-generator");
|
|
7
|
+
const { toTitleCase, pathToPostmanUrl, buildLifecycleSequences } = require("../shared/helpers");
|
|
8
|
+
|
|
9
|
+
function generateInsomniaWorkspace(options) {
|
|
10
|
+
const apiPath = path.resolve(options.apiPath);
|
|
11
|
+
const outputPath = path.resolve(options.outputPath || path.join(process.cwd(), "x-openapi-flow.insomnia.json"));
|
|
12
|
+
|
|
13
|
+
const api = loadApi(apiPath);
|
|
14
|
+
const model = buildIntermediateModel(api);
|
|
15
|
+
|
|
16
|
+
const workspaceId = "wrk_x_openapi_flow";
|
|
17
|
+
const resources = [
|
|
18
|
+
{
|
|
19
|
+
_id: workspaceId,
|
|
20
|
+
_type: "workspace",
|
|
21
|
+
name: "x-openapi-flow Workspace",
|
|
22
|
+
description: `Generated from ${apiPath}`,
|
|
23
|
+
scope: "collection",
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
for (const resource of model.resources) {
|
|
28
|
+
const groupId = `fld_${resource.resourcePropertyName}`;
|
|
29
|
+
resources.push({
|
|
30
|
+
_id: groupId,
|
|
31
|
+
_type: "request_group",
|
|
32
|
+
parentId: workspaceId,
|
|
33
|
+
name: `${toTitleCase(resource.resourcePlural || resource.resource)} Flow`,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const sequences = buildLifecycleSequences(resource);
|
|
37
|
+
const operations = sequences.length > 0
|
|
38
|
+
? Array.from(new Map(sequences.flat().map((op) => [op.operationId, op])).values())
|
|
39
|
+
: resource.operations.filter((operation) => operation.hasFlow);
|
|
40
|
+
|
|
41
|
+
operations.forEach((operation, index) => {
|
|
42
|
+
const requestId = `req_${resource.resourcePropertyName}_${index + 1}`;
|
|
43
|
+
resources.push({
|
|
44
|
+
_id: requestId,
|
|
45
|
+
_type: "request",
|
|
46
|
+
parentId: groupId,
|
|
47
|
+
name: operation.operationId,
|
|
48
|
+
method: String(operation.httpMethod || "get").toUpperCase(),
|
|
49
|
+
url: `{{ base_url }}${pathToPostmanUrl(operation.path, resource.resourcePropertyName)}`,
|
|
50
|
+
body: {},
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const exportPayload = {
|
|
56
|
+
_type: "export",
|
|
57
|
+
__export_format: 4,
|
|
58
|
+
__export_date: new Date().toISOString(),
|
|
59
|
+
__export_source: "x-openapi-flow",
|
|
60
|
+
resources,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
64
|
+
fs.writeFileSync(outputPath, `${JSON.stringify(exportPayload, null, 2)}\n`, "utf8");
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
outputPath,
|
|
68
|
+
resources: model.resources.length,
|
|
69
|
+
flowCount: model.flowCount,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { generateInsomniaWorkspace };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { loadApi } = require("../../lib/validator");
|
|
6
|
+
const { buildIntermediateModel } = require("../../lib/sdk-generator");
|
|
7
|
+
const { toTitleCase, pathToPostmanUrl, buildLifecycleSequences } = require("../shared/helpers");
|
|
8
|
+
|
|
9
|
+
function buildPostmanItem(operation, resource) {
|
|
10
|
+
const rawPath = pathToPostmanUrl(operation.path, resource.resourcePropertyName);
|
|
11
|
+
const urlRaw = `{{baseUrl}}${rawPath}`;
|
|
12
|
+
|
|
13
|
+
const item = {
|
|
14
|
+
name: operation.operationId,
|
|
15
|
+
request: {
|
|
16
|
+
method: String(operation.httpMethod || "get").toUpperCase(),
|
|
17
|
+
header: [
|
|
18
|
+
{
|
|
19
|
+
key: "Content-Type",
|
|
20
|
+
value: "application/json",
|
|
21
|
+
type: "text",
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
url: {
|
|
25
|
+
raw: urlRaw,
|
|
26
|
+
host: ["{{baseUrl}}"],
|
|
27
|
+
path: rawPath.split("/").filter(Boolean),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
response: [],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
if (["POST", "PUT", "PATCH"].includes(item.request.method)) {
|
|
34
|
+
item.request.body = {
|
|
35
|
+
mode: "raw",
|
|
36
|
+
raw: "{}",
|
|
37
|
+
options: { raw: { language: "json" } },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return item;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function addPostmanScripts(item, operation, resource) {
|
|
45
|
+
const prereqs = JSON.stringify(operation.prerequisites || []);
|
|
46
|
+
const operationId = operation.operationId;
|
|
47
|
+
const idCandidateKey = `${resource.resourcePropertyName}Id`;
|
|
48
|
+
|
|
49
|
+
item.event = [
|
|
50
|
+
{
|
|
51
|
+
listen: "prerequest",
|
|
52
|
+
script: {
|
|
53
|
+
type: "text/javascript",
|
|
54
|
+
exec: [
|
|
55
|
+
`const required = ${prereqs};`,
|
|
56
|
+
"const executed = JSON.parse(pm.collectionVariables.get('flowExecutedOps') || '[]');",
|
|
57
|
+
"const missing = required.filter((operationId) => !executed.includes(operationId));",
|
|
58
|
+
"if (missing.length > 0) {",
|
|
59
|
+
` throw new Error('Missing prerequisites for ${operationId}: ' + missing.join(', '));`,
|
|
60
|
+
"}",
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
listen: "test",
|
|
66
|
+
script: {
|
|
67
|
+
type: "text/javascript",
|
|
68
|
+
exec: [
|
|
69
|
+
"const payload = pm.response.json ? pm.response.json() : {};",
|
|
70
|
+
`if (payload && payload.id) pm.collectionVariables.set('${idCandidateKey}', payload.id);`,
|
|
71
|
+
"const executed = JSON.parse(pm.collectionVariables.get('flowExecutedOps') || '[]');",
|
|
72
|
+
`if (!executed.includes('${operationId}')) executed.push('${operationId}');`,
|
|
73
|
+
"pm.collectionVariables.set('flowExecutedOps', JSON.stringify(executed));",
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function generatePostmanCollection(options) {
|
|
81
|
+
const apiPath = path.resolve(options.apiPath);
|
|
82
|
+
const outputPath = path.resolve(options.outputPath || path.join(process.cwd(), "x-openapi-flow.postman_collection.json"));
|
|
83
|
+
const withScripts = options.withScripts !== false;
|
|
84
|
+
|
|
85
|
+
const api = loadApi(apiPath);
|
|
86
|
+
const model = buildIntermediateModel(api);
|
|
87
|
+
|
|
88
|
+
const collection = {
|
|
89
|
+
info: {
|
|
90
|
+
name: "x-openapi-flow Lifecycle Collection",
|
|
91
|
+
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
|
92
|
+
description: `Generated from ${apiPath}`,
|
|
93
|
+
},
|
|
94
|
+
item: [],
|
|
95
|
+
variable: [
|
|
96
|
+
{ key: "baseUrl", value: "http://localhost:3000" },
|
|
97
|
+
{ key: "flowExecutedOps", value: "[]" },
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
for (const resource of model.resources) {
|
|
102
|
+
const sequences = buildLifecycleSequences(resource);
|
|
103
|
+
const folder = {
|
|
104
|
+
name: `${toTitleCase(resource.resourcePlural || resource.resource)} Lifecycle`,
|
|
105
|
+
item: [],
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (sequences.length === 0) {
|
|
109
|
+
const fallbackItems = resource.operations
|
|
110
|
+
.filter((operation) => operation.hasFlow)
|
|
111
|
+
.map((operation) => {
|
|
112
|
+
const item = buildPostmanItem(operation, resource);
|
|
113
|
+
if (withScripts) addPostmanScripts(item, operation, resource);
|
|
114
|
+
return item;
|
|
115
|
+
});
|
|
116
|
+
folder.item.push(...fallbackItems);
|
|
117
|
+
} else {
|
|
118
|
+
sequences.forEach((sequence, index) => {
|
|
119
|
+
const journey = {
|
|
120
|
+
name: `Journey ${index + 1}`,
|
|
121
|
+
item: sequence.map((operation) => {
|
|
122
|
+
const item = buildPostmanItem(operation, resource);
|
|
123
|
+
if (withScripts) addPostmanScripts(item, operation, resource);
|
|
124
|
+
return item;
|
|
125
|
+
}),
|
|
126
|
+
};
|
|
127
|
+
folder.item.push(journey);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
collection.item.push(folder);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
135
|
+
fs.writeFileSync(outputPath, `${JSON.stringify(collection, null, 2)}\n`, "utf8");
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
outputPath,
|
|
139
|
+
resources: model.resources.length,
|
|
140
|
+
flowCount: model.flowCount,
|
|
141
|
+
withScripts,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
module.exports = { generatePostmanCollection };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { loadApi } = require("../../lib/validator");
|
|
6
|
+
const { buildIntermediateModel } = require("../../lib/sdk-generator");
|
|
7
|
+
const { toTitleCase, buildLifecycleSequences } = require("../shared/helpers");
|
|
8
|
+
|
|
9
|
+
function buildResourceMermaid(resource) {
|
|
10
|
+
const flowOperations = resource.operations.filter((operation) => operation.hasFlow);
|
|
11
|
+
const lines = ["stateDiagram-v2", " direction LR"];
|
|
12
|
+
|
|
13
|
+
const states = new Set(flowOperations.map((operation) => operation.currentState).filter(Boolean));
|
|
14
|
+
for (const state of [...states].sort()) {
|
|
15
|
+
lines.push(` state ${state}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const edgeSet = new Set();
|
|
19
|
+
for (const operation of flowOperations) {
|
|
20
|
+
for (const next of operation.nextOperations || []) {
|
|
21
|
+
const targetOperation = flowOperations.find((candidate) => candidate.operationId === next.nextOperationId);
|
|
22
|
+
const targetState = next.targetState || (targetOperation && targetOperation.currentState);
|
|
23
|
+
if (!targetState || !operation.currentState) continue;
|
|
24
|
+
|
|
25
|
+
const label = next.nextOperationId
|
|
26
|
+
? `${operation.methodName} -> ${next.nextOperationId}`
|
|
27
|
+
: operation.methodName;
|
|
28
|
+
const edgeKey = `${operation.currentState}::${targetState}::${label}`;
|
|
29
|
+
if (edgeSet.has(edgeKey)) continue;
|
|
30
|
+
edgeSet.add(edgeKey);
|
|
31
|
+
lines.push(` ${operation.currentState} --> ${targetState}: ${label}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return lines.join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function buildDocFlowsMarkdown(model, sourcePath) {
|
|
39
|
+
const lines = [];
|
|
40
|
+
lines.push("# API Flows");
|
|
41
|
+
lines.push("");
|
|
42
|
+
lines.push(`Source: ${sourcePath}`);
|
|
43
|
+
lines.push("");
|
|
44
|
+
lines.push("This page is generated from x-openapi-flow metadata.");
|
|
45
|
+
lines.push("");
|
|
46
|
+
|
|
47
|
+
for (const resource of model.resources) {
|
|
48
|
+
const displayName = toTitleCase(resource.resourcePlural || resource.resource);
|
|
49
|
+
lines.push(`## ${displayName} Lifecycle`);
|
|
50
|
+
lines.push("");
|
|
51
|
+
lines.push("### Flow / Lifecycle");
|
|
52
|
+
lines.push("");
|
|
53
|
+
lines.push("```mermaid");
|
|
54
|
+
lines.push(buildResourceMermaid(resource));
|
|
55
|
+
lines.push("```");
|
|
56
|
+
lines.push("");
|
|
57
|
+
|
|
58
|
+
const sequences = buildLifecycleSequences(resource);
|
|
59
|
+
if (sequences.length > 0) {
|
|
60
|
+
lines.push("### Journeys");
|
|
61
|
+
lines.push("");
|
|
62
|
+
sequences.forEach((sequence, index) => {
|
|
63
|
+
const label = sequence.map((operation) => operation.methodName).join(" -> ");
|
|
64
|
+
lines.push(`- Journey ${index + 1}: ${label}`);
|
|
65
|
+
});
|
|
66
|
+
lines.push("");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
lines.push("### Operations");
|
|
70
|
+
lines.push("");
|
|
71
|
+
for (const operation of resource.operations.filter((item) => item.hasFlow)) {
|
|
72
|
+
lines.push(`#### ${operation.operationId}`);
|
|
73
|
+
lines.push(`- Endpoint: ${operation.httpMethod.toUpperCase()} ${operation.path}`);
|
|
74
|
+
lines.push(`- Current state: ${operation.currentState || "-"}`);
|
|
75
|
+
const prereqs = operation.prerequisites && operation.prerequisites.length > 0
|
|
76
|
+
? operation.prerequisites.join(", ")
|
|
77
|
+
: "-";
|
|
78
|
+
lines.push(`- Prerequisites: ${prereqs}`);
|
|
79
|
+
|
|
80
|
+
const nextOps = (operation.nextOperations || [])
|
|
81
|
+
.map((next) => next.nextOperationId)
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
lines.push(`- Next operations: ${nextOps.length > 0 ? nextOps.join(", ") : "-"}`);
|
|
84
|
+
lines.push("");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return `${lines.join("\n").trimEnd()}\n`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function exportDocFlows(options) {
|
|
92
|
+
const apiPath = path.resolve(options.apiPath);
|
|
93
|
+
const outputPath = path.resolve(options.outputPath || path.join(process.cwd(), "api-flows.md"));
|
|
94
|
+
const format = options.format || "markdown";
|
|
95
|
+
|
|
96
|
+
if (!["markdown", "json"].includes(format)) {
|
|
97
|
+
throw new Error(`Unsupported doc flow format '${format}'. Use 'markdown' or 'json'.`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const api = loadApi(apiPath);
|
|
101
|
+
const model = buildIntermediateModel(api);
|
|
102
|
+
|
|
103
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
104
|
+
|
|
105
|
+
if (format === "json") {
|
|
106
|
+
fs.writeFileSync(outputPath, `${JSON.stringify(model, null, 2)}\n`, "utf8");
|
|
107
|
+
} else {
|
|
108
|
+
fs.writeFileSync(outputPath, buildDocFlowsMarkdown(model, apiPath), "utf8");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
outputPath,
|
|
113
|
+
format,
|
|
114
|
+
resources: model.resources.length,
|
|
115
|
+
flowCount: model.flowCount,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { exportDocFlows };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Barrel — re-exports all output adapters from their domain modules.
|
|
4
|
+
// Add new adapters to the relevant domain folder and re-export here.
|
|
5
|
+
const { exportDocFlows } = require("./docs/doc-adapter");
|
|
6
|
+
const { generatePostmanCollection } = require("./collections/postman-adapter");
|
|
7
|
+
const { generateInsomniaWorkspace } = require("./collections/insomnia-adapter");
|
|
8
|
+
const { generateRedocPackage } = require("./ui/redoc-adapter");
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
exportDocFlows,
|
|
12
|
+
generatePostmanCollection,
|
|
13
|
+
generateInsomniaWorkspace,
|
|
14
|
+
generateRedocPackage,
|
|
15
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function toTitleCase(value) {
|
|
4
|
+
return String(value || "")
|
|
5
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
6
|
+
.replace(/[_-]+/g, " ")
|
|
7
|
+
.trim()
|
|
8
|
+
.split(/\s+/)
|
|
9
|
+
.filter(Boolean)
|
|
10
|
+
.map((word) => word[0].toUpperCase() + word.slice(1).toLowerCase())
|
|
11
|
+
.join(" ");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function pathToPostmanUrl(pathTemplate, resourceKey) {
|
|
15
|
+
const variablePrefix = resourceKey || "resource";
|
|
16
|
+
return String(pathTemplate || "")
|
|
17
|
+
.replace(/\{([^}]+)\}/g, (_full, name) => `{{${variablePrefix}${toTitleCase(name).replace(/\s+/g, "")}}}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildLifecycleSequences(resource) {
|
|
21
|
+
const flowOperations = resource.operations.filter((operation) => operation.hasFlow);
|
|
22
|
+
if (flowOperations.length === 0) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const byId = new Map(flowOperations.map((operation) => [operation.operationId, operation]));
|
|
27
|
+
const indegree = new Map(flowOperations.map((operation) => [operation.operationId, 0]));
|
|
28
|
+
|
|
29
|
+
for (const operation of flowOperations) {
|
|
30
|
+
for (const next of operation.nextOperations || []) {
|
|
31
|
+
if (next.nextOperationId && indegree.has(next.nextOperationId)) {
|
|
32
|
+
indegree.set(next.nextOperationId, indegree.get(next.nextOperationId) + 1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const starts = flowOperations
|
|
38
|
+
.filter((operation) => indegree.get(operation.operationId) === 0)
|
|
39
|
+
.map((operation) => operation.operationId);
|
|
40
|
+
|
|
41
|
+
const roots = starts.length > 0 ? starts : [flowOperations[0].operationId];
|
|
42
|
+
const sequences = [];
|
|
43
|
+
|
|
44
|
+
function walk(operationId, trail, seen) {
|
|
45
|
+
if (!byId.has(operationId) || seen.has(operationId)) {
|
|
46
|
+
sequences.push(trail.slice());
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const current = byId.get(operationId);
|
|
51
|
+
trail.push(current);
|
|
52
|
+
|
|
53
|
+
const nextIds = (current.nextOperations || [])
|
|
54
|
+
.map((next) => next.nextOperationId)
|
|
55
|
+
.filter((nextId) => nextId && byId.has(nextId));
|
|
56
|
+
|
|
57
|
+
if (nextIds.length === 0) {
|
|
58
|
+
sequences.push(trail.slice());
|
|
59
|
+
trail.pop();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const nextSeen = new Set(seen);
|
|
64
|
+
nextSeen.add(operationId);
|
|
65
|
+
for (const nextId of nextIds) {
|
|
66
|
+
walk(nextId, trail, nextSeen);
|
|
67
|
+
}
|
|
68
|
+
trail.pop();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
for (const root of roots) {
|
|
72
|
+
walk(root, [], new Set());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const dedup = new Map();
|
|
76
|
+
for (const sequence of sequences) {
|
|
77
|
+
if (!sequence || sequence.length === 0) continue;
|
|
78
|
+
const key = sequence.map((operation) => operation.operationId).join("->");
|
|
79
|
+
if (!dedup.has(key)) {
|
|
80
|
+
dedup.set(key, sequence);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return [...dedup.values()];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { toTitleCase, pathToPostmanUrl, buildLifecycleSequences };
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
function createSectionTitle(text) {
|
|
5
|
+
var title = document.createElement("h3");
|
|
6
|
+
title.textContent = text;
|
|
7
|
+
title.style.margin = "16px 0 8px";
|
|
8
|
+
title.style.fontSize = "14px";
|
|
9
|
+
title.style.fontWeight = "700";
|
|
10
|
+
return title;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function listItem(text) {
|
|
14
|
+
var item = document.createElement("li");
|
|
15
|
+
item.textContent = text;
|
|
16
|
+
item.style.marginBottom = "6px";
|
|
17
|
+
return item;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function renderMermaidText(resource) {
|
|
21
|
+
var lines = ["stateDiagram-v2", " direction LR"];
|
|
22
|
+
|
|
23
|
+
var states = Array.from(new Set((resource.states || []).filter(Boolean))).sort();
|
|
24
|
+
states.forEach(function (state) {
|
|
25
|
+
lines.push(" state " + state);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
(resource.operations || []).forEach(function (operation) {
|
|
29
|
+
(operation.nextOperations || []).forEach(function (next) {
|
|
30
|
+
if (!operation.currentState || !next.targetState) return;
|
|
31
|
+
var label = next.nextOperationId || operation.operationId;
|
|
32
|
+
lines.push(" " + operation.currentState + " --> " + next.targetState + ": " + label);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return lines.join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderResourceBlock(resource) {
|
|
40
|
+
var container = document.createElement("div");
|
|
41
|
+
container.style.border = "1px solid #e5e7eb";
|
|
42
|
+
container.style.borderRadius = "8px";
|
|
43
|
+
container.style.padding = "12px";
|
|
44
|
+
container.style.marginBottom = "14px";
|
|
45
|
+
|
|
46
|
+
var title = document.createElement("h4");
|
|
47
|
+
title.textContent = (resource.resourcePlural || resource.resource || "Resource") + " Lifecycle";
|
|
48
|
+
title.style.margin = "0 0 8px";
|
|
49
|
+
title.style.fontSize = "13px";
|
|
50
|
+
container.appendChild(title);
|
|
51
|
+
|
|
52
|
+
var mermaid = document.createElement("pre");
|
|
53
|
+
mermaid.textContent = renderMermaidText(resource);
|
|
54
|
+
mermaid.style.background = "#f8fafc";
|
|
55
|
+
mermaid.style.border = "1px solid #e2e8f0";
|
|
56
|
+
mermaid.style.padding = "8px";
|
|
57
|
+
mermaid.style.borderRadius = "6px";
|
|
58
|
+
mermaid.style.whiteSpace = "pre-wrap";
|
|
59
|
+
mermaid.style.fontSize = "11px";
|
|
60
|
+
container.appendChild(mermaid);
|
|
61
|
+
|
|
62
|
+
var operationsTitle = createSectionTitle("Operations");
|
|
63
|
+
operationsTitle.style.marginTop = "10px";
|
|
64
|
+
operationsTitle.style.fontSize = "12px";
|
|
65
|
+
container.appendChild(operationsTitle);
|
|
66
|
+
|
|
67
|
+
var opList = document.createElement("ul");
|
|
68
|
+
opList.style.paddingLeft = "16px";
|
|
69
|
+
|
|
70
|
+
(resource.operations || []).forEach(function (operation) {
|
|
71
|
+
if (!operation.hasFlow) return;
|
|
72
|
+
var prerequisites = (operation.prerequisites || []).length > 0
|
|
73
|
+
? operation.prerequisites.join(", ")
|
|
74
|
+
: "-";
|
|
75
|
+
var nextOps = (operation.nextOperations || [])
|
|
76
|
+
.map(function (next) { return next.nextOperationId; })
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
var nextText = nextOps.length > 0 ? nextOps.join(", ") : "-";
|
|
79
|
+
|
|
80
|
+
opList.appendChild(
|
|
81
|
+
listItem(
|
|
82
|
+
operation.operationId
|
|
83
|
+
+ " | state=" + (operation.currentState || "-")
|
|
84
|
+
+ " | prerequisites=" + prerequisites
|
|
85
|
+
+ " | next=" + nextText
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
container.appendChild(opList);
|
|
91
|
+
return container;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function mount(options) {
|
|
95
|
+
var model = options && options.model;
|
|
96
|
+
if (!model || !Array.isArray(model.resources) || model.resources.length === 0) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
var target = document.querySelector(options.targetSelector || "#x-openapi-flow-panel");
|
|
101
|
+
if (!target) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
target.innerHTML = "";
|
|
106
|
+
|
|
107
|
+
var heading = document.createElement("h2");
|
|
108
|
+
heading.textContent = "Flow / Lifecycle";
|
|
109
|
+
heading.style.margin = "0 0 8px";
|
|
110
|
+
heading.style.fontSize = "18px";
|
|
111
|
+
target.appendChild(heading);
|
|
112
|
+
|
|
113
|
+
var subtitle = document.createElement("p");
|
|
114
|
+
subtitle.textContent = "Generated from x-openapi-flow metadata.";
|
|
115
|
+
subtitle.style.margin = "0 0 12px";
|
|
116
|
+
subtitle.style.color = "#4b5563";
|
|
117
|
+
target.appendChild(subtitle);
|
|
118
|
+
|
|
119
|
+
model.resources.forEach(function (resource) {
|
|
120
|
+
target.appendChild(renderResourceBlock(resource));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
window.XOpenApiFlowRedocPlugin = {
|
|
125
|
+
mount: mount,
|
|
126
|
+
};
|
|
127
|
+
})();
|