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
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const Handlebars = require("handlebars");
|
|
6
|
+
const { loadApi, extractFlows, buildStateGraph } = require("./validator");
|
|
7
|
+
|
|
8
|
+
const HTTP_METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
9
|
+
|
|
10
|
+
function splitWords(value) {
|
|
11
|
+
return String(value || "")
|
|
12
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
13
|
+
.replace(/[^a-zA-Z0-9]+/g, " ")
|
|
14
|
+
.trim()
|
|
15
|
+
.split(/\s+/)
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.map((word) => word.toLowerCase());
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function toPascalCase(value) {
|
|
21
|
+
return splitWords(value)
|
|
22
|
+
.map((word) => word[0].toUpperCase() + word.slice(1))
|
|
23
|
+
.join("") || "Resource";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toCamelCase(value) {
|
|
27
|
+
const words = splitWords(value);
|
|
28
|
+
if (words.length === 0) {
|
|
29
|
+
return "resource";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return words
|
|
33
|
+
.map((word, index) => {
|
|
34
|
+
if (index === 0) return word;
|
|
35
|
+
return word[0].toUpperCase() + word.slice(1);
|
|
36
|
+
})
|
|
37
|
+
.join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function singularize(value) {
|
|
41
|
+
if (!value) return "resource";
|
|
42
|
+
if (value.endsWith("ies")) return `${value.slice(0, -3)}y`;
|
|
43
|
+
if (value.endsWith("ses") || value.endsWith("xes")) return value.slice(0, -2);
|
|
44
|
+
if (value.endsWith("s") && value.length > 1) return value.slice(0, -1);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parsePathSegments(pathKey) {
|
|
49
|
+
return String(pathKey || "")
|
|
50
|
+
.split("/")
|
|
51
|
+
.filter(Boolean)
|
|
52
|
+
.map((segment) => ({
|
|
53
|
+
raw: segment,
|
|
54
|
+
value: segment.replace(/[{}]/g, ""),
|
|
55
|
+
isParam: segment.startsWith("{") && segment.endsWith("}"),
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function deriveResourceName(pathKey) {
|
|
60
|
+
const firstNonParam = parsePathSegments(pathKey).find((segment) => !segment.isParam);
|
|
61
|
+
return (firstNonParam && firstNonParam.value) || "resource";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function deriveMethodName(operationId, resourceName, pathKey, httpMethod) {
|
|
65
|
+
const operationWords = splitWords(operationId || "");
|
|
66
|
+
const singularResourceWords = splitWords(singularize(resourceName));
|
|
67
|
+
const pluralResourceWords = splitWords(resourceName);
|
|
68
|
+
|
|
69
|
+
const trimmed = operationWords.filter((word) => {
|
|
70
|
+
return !singularResourceWords.includes(word) && !pluralResourceWords.includes(word);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (trimmed.length > 0) {
|
|
74
|
+
return toCamelCase(trimmed.join(" "));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const staticSegments = parsePathSegments(pathKey)
|
|
78
|
+
.filter((segment) => !segment.isParam)
|
|
79
|
+
.map((segment) => segment.value);
|
|
80
|
+
|
|
81
|
+
if (staticSegments.length > 1) {
|
|
82
|
+
return toCamelCase(staticSegments[staticSegments.length - 1]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return httpMethod === "post" ? "create" : toCamelCase(httpMethod);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function parsePathParams(pathKey) {
|
|
89
|
+
return parsePathSegments(pathKey)
|
|
90
|
+
.filter((segment) => segment.isParam)
|
|
91
|
+
.map((segment) => segment.value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function buildPathTemplate(pathKey, paramSource) {
|
|
95
|
+
return String(pathKey).replace(/\{([^}]+)\}/g, (_full, paramName) => `\${${paramSource}["${paramName}"]}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function classifyOperation(operation) {
|
|
99
|
+
const segments = parsePathSegments(operation.path);
|
|
100
|
+
const staticSegments = segments.filter((segment) => !segment.isParam).map((segment) => segment.value);
|
|
101
|
+
const hasPrimaryResource = staticSegments[0] === operation.resourceName;
|
|
102
|
+
|
|
103
|
+
if (hasPrimaryResource && segments.length === 1 && operation.httpMethod === "post") {
|
|
104
|
+
return "create";
|
|
105
|
+
}
|
|
106
|
+
if (hasPrimaryResource && segments.length === 1 && operation.httpMethod === "get") {
|
|
107
|
+
return "list";
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
hasPrimaryResource
|
|
111
|
+
&& segments.length === 2
|
|
112
|
+
&& segments[1].isParam
|
|
113
|
+
&& operation.httpMethod === "get"
|
|
114
|
+
) {
|
|
115
|
+
return "retrieve";
|
|
116
|
+
}
|
|
117
|
+
if (
|
|
118
|
+
hasPrimaryResource
|
|
119
|
+
&& segments.length === 2
|
|
120
|
+
&& segments[1].isParam
|
|
121
|
+
&& (operation.httpMethod === "put" || operation.httpMethod === "patch")
|
|
122
|
+
) {
|
|
123
|
+
return "update";
|
|
124
|
+
}
|
|
125
|
+
if (
|
|
126
|
+
hasPrimaryResource
|
|
127
|
+
&& segments.length === 2
|
|
128
|
+
&& segments[1].isParam
|
|
129
|
+
&& operation.httpMethod === "delete"
|
|
130
|
+
) {
|
|
131
|
+
return "delete";
|
|
132
|
+
}
|
|
133
|
+
if (hasPrimaryResource && segments.length >= 3 && segments[1].isParam) {
|
|
134
|
+
return "action";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return "custom";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function extractOpenApiOperations(api) {
|
|
141
|
+
const operations = [];
|
|
142
|
+
const paths = (api && api.paths) || {};
|
|
143
|
+
|
|
144
|
+
for (const [pathKey, pathItem] of Object.entries(paths)) {
|
|
145
|
+
for (const method of HTTP_METHODS) {
|
|
146
|
+
const operation = pathItem[method];
|
|
147
|
+
if (!operation) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const operationId = operation.operationId || `${method}_${pathKey.replace(/[^a-zA-Z0-9]+/g, "_")}`;
|
|
152
|
+
const resourceName = deriveResourceName(pathKey);
|
|
153
|
+
const methodName = deriveMethodName(operationId, resourceName, pathKey, method);
|
|
154
|
+
const hasFlow = !!operation["x-openapi-flow"];
|
|
155
|
+
|
|
156
|
+
operations.push({
|
|
157
|
+
operationId,
|
|
158
|
+
path: pathKey,
|
|
159
|
+
httpMethod: method,
|
|
160
|
+
flow: hasFlow ? operation["x-openapi-flow"] : null,
|
|
161
|
+
hasFlow,
|
|
162
|
+
resourceName,
|
|
163
|
+
methodName,
|
|
164
|
+
pathParams: parsePathParams(pathKey),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return operations.map((operation) => ({
|
|
170
|
+
...operation,
|
|
171
|
+
kind: classifyOperation(operation),
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function resolveNextOperationId(transition, flowOperationsByResource, resourceName) {
|
|
176
|
+
if (transition.next_operation_id) {
|
|
177
|
+
return transition.next_operation_id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const resourceOperations = flowOperationsByResource.get(resourceName) || [];
|
|
181
|
+
const byState = resourceOperations.find((operation) => operation.flow.current_state === transition.target_state);
|
|
182
|
+
return byState ? byState.operationId : null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildIntermediateModel(api) {
|
|
186
|
+
const flows = extractFlows(api);
|
|
187
|
+
const operations = extractOpenApiOperations(api);
|
|
188
|
+
|
|
189
|
+
const operationsByResource = new Map();
|
|
190
|
+
for (const operation of operations) {
|
|
191
|
+
if (!operationsByResource.has(operation.resourceName)) {
|
|
192
|
+
operationsByResource.set(operation.resourceName, []);
|
|
193
|
+
}
|
|
194
|
+
operationsByResource.get(operation.resourceName).push(operation);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const flowOperationsByResource = new Map();
|
|
198
|
+
for (const [resourceName, resourceOperations] of operationsByResource.entries()) {
|
|
199
|
+
flowOperationsByResource.set(
|
|
200
|
+
resourceName,
|
|
201
|
+
resourceOperations.filter((operation) => operation.hasFlow)
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const resources = [];
|
|
206
|
+
|
|
207
|
+
for (const [resourceName, resourceOperations] of operationsByResource.entries()) {
|
|
208
|
+
const flowOperations = resourceOperations.filter((operation) => operation.hasFlow);
|
|
209
|
+
if (flowOperations.length === 0) {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const operationsById = new Map(resourceOperations.map((operation) => [operation.operationId, operation]));
|
|
214
|
+
|
|
215
|
+
const resourceFlows = flowOperations.map((operation) => ({
|
|
216
|
+
endpoint: `${operation.httpMethod.toUpperCase()} ${operation.path}`,
|
|
217
|
+
operation_id: operation.operationId,
|
|
218
|
+
flow: operation.flow,
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
const graph = buildStateGraph(resourceFlows);
|
|
222
|
+
const initialStates = [...graph.nodes].filter((state) => (graph.indegree.get(state) || 0) === 0);
|
|
223
|
+
const terminalStates = [...graph.nodes].filter((state) => (graph.outdegree.get(state) || 0) === 0);
|
|
224
|
+
|
|
225
|
+
const incomingPrerequisites = new Map();
|
|
226
|
+
const nextOperationsMap = new Map();
|
|
227
|
+
|
|
228
|
+
for (const operation of flowOperations) {
|
|
229
|
+
const transitions = Array.isArray(operation.flow.transitions) ? operation.flow.transitions : [];
|
|
230
|
+
const nextOps = transitions
|
|
231
|
+
.map((transition) => {
|
|
232
|
+
const nextOperationId = resolveNextOperationId(transition, flowOperationsByResource, resourceName);
|
|
233
|
+
if (!nextOperationId) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const prerequisiteSet = new Set([
|
|
238
|
+
operation.operationId,
|
|
239
|
+
...((Array.isArray(transition.prerequisite_operation_ids)
|
|
240
|
+
? transition.prerequisite_operation_ids
|
|
241
|
+
: [])),
|
|
242
|
+
]);
|
|
243
|
+
|
|
244
|
+
if (!incomingPrerequisites.has(nextOperationId)) {
|
|
245
|
+
incomingPrerequisites.set(nextOperationId, new Set());
|
|
246
|
+
}
|
|
247
|
+
prerequisiteSet.forEach((prereq) => incomingPrerequisites.get(nextOperationId).add(prereq));
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
targetState: transition.target_state || null,
|
|
251
|
+
triggerType: transition.trigger_type || null,
|
|
252
|
+
nextOperationId,
|
|
253
|
+
prerequisites: [...prerequisiteSet],
|
|
254
|
+
};
|
|
255
|
+
})
|
|
256
|
+
.filter(Boolean);
|
|
257
|
+
|
|
258
|
+
nextOperationsMap.set(operation.operationId, nextOps);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const operationModels = resourceOperations.map((operation) => ({
|
|
262
|
+
operationId: operation.operationId,
|
|
263
|
+
methodName: operation.methodName,
|
|
264
|
+
helperMethodName: operation.kind === "action" ? operation.methodName : null,
|
|
265
|
+
kind: operation.kind,
|
|
266
|
+
httpMethod: operation.httpMethod,
|
|
267
|
+
path: operation.path,
|
|
268
|
+
pathParams: operation.pathParams,
|
|
269
|
+
hasFlow: operation.hasFlow,
|
|
270
|
+
currentState: operation.hasFlow ? operation.flow.current_state : null,
|
|
271
|
+
prerequisites: [...(incomingPrerequisites.get(operation.operationId) || [])],
|
|
272
|
+
nextOperations: operation.hasFlow ? (nextOperationsMap.get(operation.operationId) || []) : [],
|
|
273
|
+
}));
|
|
274
|
+
|
|
275
|
+
const stateSet = Array.from(
|
|
276
|
+
new Set(flowOperations.map((operation) => operation.flow.current_state))
|
|
277
|
+
).sort();
|
|
278
|
+
|
|
279
|
+
resources.push({
|
|
280
|
+
resource: singularize(resourceName),
|
|
281
|
+
resourcePlural: resourceName,
|
|
282
|
+
resourceClassName: toPascalCase(singularize(resourceName)),
|
|
283
|
+
resourcePropertyName: toCamelCase(resourceName),
|
|
284
|
+
operations: operationModels,
|
|
285
|
+
states: stateSet,
|
|
286
|
+
prerequisites: operationModels.reduce((acc, operation) => {
|
|
287
|
+
acc[operation.operationId] = operation.prerequisites;
|
|
288
|
+
return acc;
|
|
289
|
+
}, {}),
|
|
290
|
+
nextOperations: operationModels.reduce((acc, operation) => {
|
|
291
|
+
acc[operation.operationId] = operation.nextOperations;
|
|
292
|
+
return acc;
|
|
293
|
+
}, {}),
|
|
294
|
+
graph: {
|
|
295
|
+
initialStates,
|
|
296
|
+
terminalStates,
|
|
297
|
+
nodes: [...graph.nodes],
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
flowCount: flows.length,
|
|
304
|
+
resources,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function renderTemplate(templatePath, data) {
|
|
309
|
+
const template = fs.readFileSync(templatePath, "utf8");
|
|
310
|
+
return Handlebars.compile(template, { noEscape: true })(data);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function buildPathResolutionCode(pathParams, pathPattern, sourceVar) {
|
|
314
|
+
if (pathParams.length === 0) {
|
|
315
|
+
return `const requestPath = \`${pathPattern}\`;`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const lines = [];
|
|
319
|
+
for (const param of pathParams) {
|
|
320
|
+
lines.push(`const ${param} = ${sourceVar}["${param}"] as string | undefined;`);
|
|
321
|
+
lines.push(`if (!${param}) { throw new Error("Missing required path parameter '${param}'."); }`);
|
|
322
|
+
}
|
|
323
|
+
const mapping = pathParams.map((param) => `${param},`).join(" ");
|
|
324
|
+
lines.push(`const resolvedPathParams = { ${mapping} };`);
|
|
325
|
+
lines.push(`const requestPath = \`${buildPathTemplate(pathPattern, "resolvedPathParams")}\`;`);
|
|
326
|
+
return lines.join("\n ");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function buildOperationCallCase(operation) {
|
|
330
|
+
const pathCode = buildPathResolutionCode(operation.pathParams, operation.path, "params");
|
|
331
|
+
return [
|
|
332
|
+
` case "${operation.operationId}": {`,
|
|
333
|
+
` ${pathCode}`,
|
|
334
|
+
` return this.httpClient.request("${operation.httpMethod.toUpperCase()}", requestPath, {`,
|
|
335
|
+
" body: params.body,",
|
|
336
|
+
" headers: params.headers as Record<string, string> | undefined,",
|
|
337
|
+
" });",
|
|
338
|
+
" }",
|
|
339
|
+
].join("\n");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function buildServiceMethod(operation, returnType, defaults) {
|
|
343
|
+
const needsId = operation.pathParams.includes("id");
|
|
344
|
+
const args = [];
|
|
345
|
+
if (needsId) {
|
|
346
|
+
args.push("id: string");
|
|
347
|
+
}
|
|
348
|
+
args.push("params: OperationParams = {}");
|
|
349
|
+
if (defaults.withLifecycleOptions) {
|
|
350
|
+
args.push("options: LifecycleOptions = {}");
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const mergedParamsLine = needsId
|
|
354
|
+
? "const mergedParams: OperationParams = { ...params, id };"
|
|
355
|
+
: "const mergedParams: OperationParams = { ...params };";
|
|
356
|
+
|
|
357
|
+
const lifecycleOptions = defaults.withLifecycleOptions
|
|
358
|
+
? `{
|
|
359
|
+
autoPrerequisites: options.autoPrerequisites ?? ${defaults.autoPrerequisitesDefault ? "true" : "false"},
|
|
360
|
+
prerequisiteParams: options.prerequisiteParams || {},
|
|
361
|
+
context: options.context,
|
|
362
|
+
}`
|
|
363
|
+
: "{ autoPrerequisites: false, prerequisiteParams: {}, context: undefined }";
|
|
364
|
+
|
|
365
|
+
return [
|
|
366
|
+
` async ${operation.methodName}(${args.join(", ")}): Promise<${returnType}> {`,
|
|
367
|
+
` ${mergedParamsLine}`,
|
|
368
|
+
` return this.executeOperation("${operation.operationId}", mergedParams, ${lifecycleOptions}) as Promise<${returnType}>;`,
|
|
369
|
+
" }",
|
|
370
|
+
].join("\n");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function buildTransitionMethod(transitionOperation, targetStateClassName) {
|
|
374
|
+
const prerequisites = JSON.stringify(transitionOperation.prerequisites || []);
|
|
375
|
+
const mergeId = transitionOperation.pathParams.includes("id")
|
|
376
|
+
? "const mergedParams: OperationParams = this.id ? { ...params, id: this.id } : { ...params };"
|
|
377
|
+
: "const mergedParams: OperationParams = { ...params };";
|
|
378
|
+
|
|
379
|
+
return [
|
|
380
|
+
` async ${transitionOperation.methodName}(params: OperationParams = {}): Promise<${targetStateClassName}> {`,
|
|
381
|
+
` ensurePrerequisites(this.completedOperations, ${prerequisites}, "${transitionOperation.methodName}");`,
|
|
382
|
+
` ${mergeId}`,
|
|
383
|
+
` return this.service._executeTransition("${transitionOperation.operationId}", mergedParams, this.completedOperations) as Promise<${targetStateClassName}>;`,
|
|
384
|
+
" }",
|
|
385
|
+
].join("\n");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function buildTypeScriptResourceCode(resourceModel) {
|
|
389
|
+
const flowOperations = resourceModel.operations.filter((operation) => operation.hasFlow);
|
|
390
|
+
const operationsById = new Map(resourceModel.operations.map((operation) => [operation.operationId, operation]));
|
|
391
|
+
|
|
392
|
+
const stateClassNameByState = new Map(
|
|
393
|
+
resourceModel.states.map((state) => [state, `${resourceModel.resourceClassName}${toPascalCase(state)}`])
|
|
394
|
+
);
|
|
395
|
+
|
|
396
|
+
const flowStateClassByOperationId = flowOperations.reduce((acc, operation) => {
|
|
397
|
+
acc[operation.operationId] = stateClassNameByState.get(operation.currentState);
|
|
398
|
+
return acc;
|
|
399
|
+
}, {});
|
|
400
|
+
|
|
401
|
+
const collectionMethods = [];
|
|
402
|
+
|
|
403
|
+
const createOp = resourceModel.operations.find((operation) => operation.kind === "create");
|
|
404
|
+
if (createOp) {
|
|
405
|
+
const returnType = createOp.hasFlow
|
|
406
|
+
? flowStateClassByOperationId[createOp.operationId]
|
|
407
|
+
: `${resourceModel.resourceClassName}ResourceInstance`;
|
|
408
|
+
collectionMethods.push(
|
|
409
|
+
buildServiceMethod(createOp, returnType, {
|
|
410
|
+
withLifecycleOptions: false,
|
|
411
|
+
autoPrerequisitesDefault: false,
|
|
412
|
+
})
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const retrieveOp = resourceModel.operations.find((operation) => operation.kind === "retrieve");
|
|
417
|
+
if (retrieveOp) {
|
|
418
|
+
collectionMethods.push(
|
|
419
|
+
buildServiceMethod(retrieveOp, `${resourceModel.resourceClassName}ResourceInstance`, {
|
|
420
|
+
withLifecycleOptions: false,
|
|
421
|
+
autoPrerequisitesDefault: false,
|
|
422
|
+
})
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const listOp = resourceModel.operations.find((operation) => operation.kind === "list");
|
|
427
|
+
if (listOp) {
|
|
428
|
+
collectionMethods.push(
|
|
429
|
+
buildServiceMethod(listOp, "unknown", {
|
|
430
|
+
withLifecycleOptions: false,
|
|
431
|
+
autoPrerequisitesDefault: false,
|
|
432
|
+
})
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const helperMethods = [];
|
|
437
|
+
const seenHelperNames = new Set(collectionMethods.map((method) => method.match(/async\s+([a-zA-Z0-9_]+)/)[1]));
|
|
438
|
+
for (const operation of flowOperations) {
|
|
439
|
+
if (seenHelperNames.has(operation.methodName)) {
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
helperMethods.push(
|
|
443
|
+
buildServiceMethod(operation, flowStateClassByOperationId[operation.operationId], {
|
|
444
|
+
withLifecycleOptions: true,
|
|
445
|
+
autoPrerequisitesDefault: true,
|
|
446
|
+
})
|
|
447
|
+
);
|
|
448
|
+
seenHelperNames.add(operation.methodName);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const operationCallCases = resourceModel.operations.map(buildOperationCallCase).join("\n\n");
|
|
452
|
+
|
|
453
|
+
const operationPrereqMap = JSON.stringify(resourceModel.prerequisites || {}, null, 2)
|
|
454
|
+
.replace(/^/gm, " ")
|
|
455
|
+
.trimEnd();
|
|
456
|
+
|
|
457
|
+
const stateFactoryCases = resourceModel.operations.map((operation) => {
|
|
458
|
+
const className = operation.hasFlow
|
|
459
|
+
? flowStateClassByOperationId[operation.operationId]
|
|
460
|
+
: `${resourceModel.resourceClassName}ResourceInstance`;
|
|
461
|
+
return ` case "${operation.operationId}":\n return new ${className}(this, instanceId, completed);`;
|
|
462
|
+
}).join("\n");
|
|
463
|
+
|
|
464
|
+
const transitionMethodsByState = new Map(resourceModel.states.map((state) => [state, []]));
|
|
465
|
+
|
|
466
|
+
for (const flowOperation of flowOperations) {
|
|
467
|
+
for (const transition of flowOperation.nextOperations || []) {
|
|
468
|
+
const targetOperation = operationsById.get(transition.nextOperationId);
|
|
469
|
+
if (!targetOperation || !targetOperation.hasFlow) {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const sourceState = flowOperation.currentState;
|
|
474
|
+
const targetStateClassName = stateClassNameByState.get(targetOperation.currentState);
|
|
475
|
+
const stateMethods = transitionMethodsByState.get(sourceState) || [];
|
|
476
|
+
|
|
477
|
+
if (stateMethods.some((methodCode) => methodCode.includes(`async ${targetOperation.methodName}(`))) {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
stateMethods.push(buildTransitionMethod(targetOperation, targetStateClassName));
|
|
482
|
+
transitionMethodsByState.set(sourceState, stateMethods);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const stateClassesCode = [
|
|
487
|
+
`export class ${resourceModel.resourceClassName}ResourceInstance {`,
|
|
488
|
+
" constructor(",
|
|
489
|
+
` protected readonly service: ${resourceModel.resourceClassName}Resource,`,
|
|
490
|
+
" protected readonly id?: string,",
|
|
491
|
+
" protected readonly completedOperations: Set<string> = new Set(),",
|
|
492
|
+
" ) {}",
|
|
493
|
+
"",
|
|
494
|
+
" get resourceId(): string | undefined {",
|
|
495
|
+
" return this.id;",
|
|
496
|
+
" }",
|
|
497
|
+
"}",
|
|
498
|
+
];
|
|
499
|
+
|
|
500
|
+
for (const stateName of resourceModel.states) {
|
|
501
|
+
const className = stateClassNameByState.get(stateName);
|
|
502
|
+
const methods = transitionMethodsByState.get(stateName) || [];
|
|
503
|
+
stateClassesCode.push("");
|
|
504
|
+
stateClassesCode.push(`export class ${className} extends ${resourceModel.resourceClassName}ResourceInstance {`);
|
|
505
|
+
if (methods.length > 0) {
|
|
506
|
+
stateClassesCode.push(methods.join("\n\n"));
|
|
507
|
+
}
|
|
508
|
+
stateClassesCode.push("}");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const sharedTypesCode = [
|
|
512
|
+
"type OperationParams = {",
|
|
513
|
+
" body?: unknown;",
|
|
514
|
+
" headers?: Record<string, string>;",
|
|
515
|
+
" [key: string]: unknown;",
|
|
516
|
+
"};",
|
|
517
|
+
"",
|
|
518
|
+
"type LifecycleContext = {",
|
|
519
|
+
" executed: Set<string>;",
|
|
520
|
+
"};",
|
|
521
|
+
"",
|
|
522
|
+
"type LifecycleOptions = {",
|
|
523
|
+
" autoPrerequisites?: boolean;",
|
|
524
|
+
" prerequisiteParams?: Record<string, OperationParams>;",
|
|
525
|
+
" context?: LifecycleContext;",
|
|
526
|
+
"};",
|
|
527
|
+
].join("\n");
|
|
528
|
+
|
|
529
|
+
const serviceMethodsCode = [
|
|
530
|
+
...collectionMethods,
|
|
531
|
+
...helperMethods,
|
|
532
|
+
"",
|
|
533
|
+
` async _executeTransition(operationId: string, params: OperationParams, completedOperations: Set<string>): Promise<unknown> {`,
|
|
534
|
+
" return this.executeOperation(operationId, params, {",
|
|
535
|
+
" autoPrerequisites: false,",
|
|
536
|
+
" prerequisiteParams: {},",
|
|
537
|
+
" context: { executed: new Set(completedOperations) },",
|
|
538
|
+
" });",
|
|
539
|
+
" }",
|
|
540
|
+
"",
|
|
541
|
+
" private async executeOperation(",
|
|
542
|
+
" operationId: string,",
|
|
543
|
+
" params: OperationParams,",
|
|
544
|
+
" options: { autoPrerequisites: boolean; prerequisiteParams: Record<string, OperationParams>; context?: LifecycleContext },",
|
|
545
|
+
" ): Promise<unknown> {",
|
|
546
|
+
" const context = options.context || { executed: new Set<string>() };",
|
|
547
|
+
"",
|
|
548
|
+
" if (options.autoPrerequisites) {",
|
|
549
|
+
" const prerequisitesByOperation: Record<string, string[]> =",
|
|
550
|
+
` ${operationPrereqMap || "{}"};`,
|
|
551
|
+
" const required = prerequisitesByOperation[operationId] || [];",
|
|
552
|
+
" for (const prerequisiteOperationId of required) {",
|
|
553
|
+
" if (context.executed.has(prerequisiteOperationId)) continue;",
|
|
554
|
+
" const prerequisiteParams = options.prerequisiteParams[prerequisiteOperationId] || params;",
|
|
555
|
+
" await this.executeOperation(prerequisiteOperationId, prerequisiteParams, {",
|
|
556
|
+
" autoPrerequisites: true,",
|
|
557
|
+
" prerequisiteParams: options.prerequisiteParams,",
|
|
558
|
+
" context,",
|
|
559
|
+
" });",
|
|
560
|
+
" }",
|
|
561
|
+
" }",
|
|
562
|
+
"",
|
|
563
|
+
" const response = await this.callOperation(operationId, params);",
|
|
564
|
+
" context.executed.add(operationId);",
|
|
565
|
+
" return this.buildResourceInstance(operationId, response, context.executed, params);",
|
|
566
|
+
" }",
|
|
567
|
+
"",
|
|
568
|
+
" private async callOperation(operationId: string, params: OperationParams): Promise<unknown> {",
|
|
569
|
+
" switch (operationId) {",
|
|
570
|
+
operationCallCases,
|
|
571
|
+
" default:",
|
|
572
|
+
" throw new Error(`Unknown operationId '${operationId}' for resource.`);",
|
|
573
|
+
" }",
|
|
574
|
+
" }",
|
|
575
|
+
"",
|
|
576
|
+
" private buildResourceInstance(",
|
|
577
|
+
" operationId: string,",
|
|
578
|
+
" response: unknown,",
|
|
579
|
+
" completedOperations: Set<string>,",
|
|
580
|
+
" params: OperationParams,",
|
|
581
|
+
" ): unknown {",
|
|
582
|
+
" const responseId = (response as { id?: string } | undefined)?.id;",
|
|
583
|
+
" const instanceId = responseId ?? (params[\"id\"] as string | undefined);",
|
|
584
|
+
" const completed = new Set(completedOperations);",
|
|
585
|
+
" completed.add(operationId);",
|
|
586
|
+
"",
|
|
587
|
+
" switch (operationId) {",
|
|
588
|
+
stateFactoryCases,
|
|
589
|
+
" default:",
|
|
590
|
+
` return new ${resourceModel.resourceClassName}ResourceInstance(this, instanceId, completed);`,
|
|
591
|
+
" }",
|
|
592
|
+
" }",
|
|
593
|
+
].join("\n\n");
|
|
594
|
+
|
|
595
|
+
return {
|
|
596
|
+
resourceClassName: resourceModel.resourceClassName,
|
|
597
|
+
sharedTypesCode,
|
|
598
|
+
stateClassesCode: stateClassesCode.join("\n"),
|
|
599
|
+
serviceMethodsCode,
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function generateTypeScriptSdk(intermediateModel, outputDir) {
|
|
604
|
+
const templateRoot = path.join(__dirname, "..", "templates", "typescript");
|
|
605
|
+
const srcDir = path.join(outputDir, "src");
|
|
606
|
+
const resourcesDir = path.join(srcDir, "resources");
|
|
607
|
+
|
|
608
|
+
fs.mkdirSync(resourcesDir, { recursive: true });
|
|
609
|
+
|
|
610
|
+
for (const resourceModel of intermediateModel.resources) {
|
|
611
|
+
const resourceTemplateData = buildTypeScriptResourceCode(resourceModel);
|
|
612
|
+
const resourceOutput = renderTemplate(path.join(templateRoot, "resource.hbs"), resourceTemplateData);
|
|
613
|
+
fs.writeFileSync(
|
|
614
|
+
path.join(resourcesDir, `${resourceModel.resourceClassName}.ts`),
|
|
615
|
+
`${resourceOutput.trimEnd()}\n`,
|
|
616
|
+
"utf8"
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const indexOutput = renderTemplate(path.join(templateRoot, "index.hbs"), {
|
|
621
|
+
resources: intermediateModel.resources,
|
|
622
|
+
});
|
|
623
|
+
fs.writeFileSync(path.join(srcDir, "index.ts"), `${indexOutput.trimEnd()}\n`, "utf8");
|
|
624
|
+
|
|
625
|
+
const httpClientOutput = renderTemplate(path.join(templateRoot, "http-client.hbs"), {});
|
|
626
|
+
fs.writeFileSync(path.join(srcDir, "http-client.ts"), `${httpClientOutput.trimEnd()}\n`, "utf8");
|
|
627
|
+
|
|
628
|
+
const helpersOutput = renderTemplate(path.join(templateRoot, "flow-helpers.hbs"), {});
|
|
629
|
+
fs.writeFileSync(path.join(srcDir, "flow-helpers.ts"), `${helpersOutput.trimEnd()}\n`, "utf8");
|
|
630
|
+
|
|
631
|
+
fs.writeFileSync(
|
|
632
|
+
path.join(outputDir, "flow-model.json"),
|
|
633
|
+
`${JSON.stringify(intermediateModel, null, 2)}\n`,
|
|
634
|
+
"utf8"
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function generateSdk(options) {
|
|
639
|
+
const apiPath = path.resolve(options.apiPath);
|
|
640
|
+
const outputDir = path.resolve(options.outputDir);
|
|
641
|
+
const language = options.language || "typescript";
|
|
642
|
+
|
|
643
|
+
if (language !== "typescript") {
|
|
644
|
+
throw new Error(`Unsupported language '${language}'. MVP currently supports only 'typescript'.`);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const api = loadApi(apiPath);
|
|
648
|
+
const model = buildIntermediateModel(api);
|
|
649
|
+
|
|
650
|
+
if (model.resources.length === 0) {
|
|
651
|
+
throw new Error("No x-openapi-flow operations found. Add x-openapi-flow metadata before generating an SDK.");
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
generateTypeScriptSdk(model, outputDir);
|
|
655
|
+
|
|
656
|
+
return {
|
|
657
|
+
language,
|
|
658
|
+
outputDir,
|
|
659
|
+
flowCount: model.flowCount,
|
|
660
|
+
resourceCount: model.resources.length,
|
|
661
|
+
resources: model.resources.map((resource) => ({
|
|
662
|
+
name: resource.resourceClassName,
|
|
663
|
+
operations: resource.operations.length,
|
|
664
|
+
states: resource.states.length,
|
|
665
|
+
initialStates: resource.graph.initialStates,
|
|
666
|
+
})),
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
module.exports = {
|
|
671
|
+
buildIntermediateModel,
|
|
672
|
+
generateSdk,
|
|
673
|
+
};
|